cockpit/pkg/users/local.js

1605 lines
53 KiB
JavaScript
Executable File

/*
* This file is part of Cockpit.
*
* Copyright (C) 2013 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/
import 'polyfills'; // once per application
import $ from 'jquery';
import cockpit from 'cockpit';
import moment from "moment";
import React from 'react';
import ReactDOM from 'react-dom';
import { mustache } from 'mustache';
import * as authorized_keys from './authorized-keys.js';
import 'patterns';
import 'bootstrap-datepicker/dist/js/bootstrap-datepicker';
import 'form-layout.scss';
import { Badge } from '@patternfly/react-core';
moment.locale(cockpit.language);
const _ = cockpit.gettext;
const C_ = cockpit.gettext;
function passwd_self(old_pass, new_pass) {
var old_exps = [
/Current password: $/,
/.*\(current\) UNIX password: $/,
];
var new_exps = [
/.*New password: $/,
/.*Retype new password: $/,
/.*Enter new \w*\s?password: $/,
/.*Retype new \w*\s?password: $/
];
var bad_exps = [
/.*BAD PASSWORD:.*/
];
var too_new_exps = [
/.*must wait longer to change.*/
];
var dfd = cockpit.defer();
var buffer = "";
var sent_new = false;
var failure = _("Old password not accepted");
var i;
var proc;
var timeout = window.setTimeout(function() {
failure = _("Prompting via passwd timed out");
proc.close("terminated");
}, 10 * 1000);
proc = cockpit.spawn(["/usr/bin/passwd"], { pty: true, environ: ["LC_ALL=C"], err: "out" })
.always(function() {
window.clearInterval(timeout);
})
.done(function() {
dfd.resolve();
})
.fail(function(ex) {
if (ex.exit_status)
ex = new Error(failure);
dfd.reject(ex);
})
.stream(function(data) {
buffer += data;
for (i = 0; i < old_exps.length; i++) {
if (old_exps[i].test(buffer)) {
buffer = "";
this.input(old_pass + "\n", true);
return;
}
}
for (i = 0; i < too_new_exps.length; i++) {
if (too_new_exps[i].test(buffer)) {
buffer = "";
failure = _("You must wait longer to change your password");
this.input("\n", true);
return;
}
}
for (i = 0; i < new_exps.length; i++) {
if (new_exps[i].test(buffer)) {
buffer = "";
this.input(new_pass + "\n", true);
failure = _("Failed to change password");
sent_new = true;
return;
}
}
if (sent_new)
for (i = 0; i < bad_exps.length; i++) {
if (bad_exps[i].test(buffer)) {
failure = _("New password was not accepted");
return;
}
}
});
return dfd.promise();
}
function passwd_change(user, new_pass) {
var dfd = cockpit.defer();
cockpit.spawn(["chpasswd"], { superuser: "require", err: "out" })
.input(user + ":" + new_pass)
.done(function() {
dfd.resolve();
})
.fail(function(ex, response) {
if (ex.exit_status) {
console.log(ex);
if (response)
ex = new Error(response);
else
ex = new Error(_("Failed to change password"));
}
dfd.reject(ex);
});
return dfd.promise();
}
/*
* Similar to $.when() but serializes, and accepts functions
* that return promises
*/
function chain(functions) {
var dfd = cockpit.defer();
var i = 0;
/* Either an array or functions passed */
if (typeof functions == "function")
functions = arguments;
function step() {
if (i == functions.length) {
dfd.resolve();
return;
}
(functions[i])()
.done(function() {
step();
})
.fail(function(ex) {
dfd.reject(ex);
});
i += 1;
}
step();
return dfd.promise();
}
function parse_passwd_content(content) {
if (!content) {
console.warn("Couldn't read /etc/passwd");
return [];
}
var ret = [];
var lines = content.split('\n');
var column;
for (var i = 0; i < lines.length; i++) {
if (!lines[i])
continue;
column = lines[i].split(':');
ret.push({
name: column[0],
password: column[1],
uid: parseInt(column[2], 10),
gid: parseInt(column[3], 10),
gecos: (column[4] || '').replace(/,*$/, ''),
home: column[5] || '',
shell: column[6] || '',
});
}
return ret;
}
function parse_group_content(content) {
content = (content || "").trim();
if (!content) {
console.warn("Couldn't read /etc/group");
return [];
}
var ret = [];
var lines = content.split('\n');
var column;
for (var i = 0; i < lines.length; i++) {
if (!lines[i])
continue;
column = lines[i].split(':');
ret.push({
name: column[0],
password: column[1],
gid: parseInt(column[2], 10),
userlist: column[3].split(','),
});
}
return ret;
}
function password_quality(password) {
var dfd = cockpit.defer();
cockpit.spawn('/usr/bin/pwscore', { err: "message" })
.input(password)
.done(function(content) {
var quality = parseInt(content, 10);
if (quality === 0) {
dfd.reject(new Error(_("Password is too weak")));
} else if (quality <= 33) {
dfd.resolve("weak");
} else if (quality <= 66) {
dfd.resolve("okay");
} else if (quality <= 99) {
dfd.resolve("good");
} else {
dfd.resolve("excellent");
}
})
.fail(function(ex) {
dfd.reject(new Error(ex.message || _("Password is not acceptable")));
});
return dfd.promise();
}
function is_user_in_group(user, group) {
for (var i = 0; group.userlist && i < group.userlist.length; i++) {
if (group.userlist[i] === user)
return true;
}
return false;
}
class AccountItem extends React.Component {
constructor(props) {
super(props);
this.click = this.click.bind(this);
}
click(ev) {
if (!ev)
return;
if (ev.type === 'click' && ev.button !== 0)
return;
if (ev.type === 'keypress' && ev.key !== "Enter")
return;
cockpit.location.go([this.props.name]);
}
render() {
return (
<li className="cockpit-account" role="presentation" onClick={this.click} onKeyPress={this.click}>
<div className="cockpit-account-pic pficon pficon-user" />
<div className="cockpit-account-real-name">{this.props.gecos.split(',')[0]}</div>
<div className="cockpit-account-user-name">
<a href={"#/" + this.props.name}>{this.props.name}</a>
{this.props.current && <Badge className="cockpit-account-badge">{_("Your account")}</Badge>}
</div>
</li>
);
}
}
AccountItem.displayName = 'AccountItem';
class AccountList extends React.Component {
render() {
var i;
var items = [];
for (i in this.props.accounts)
items.push(React.createElement(AccountItem, Object.assign({
key: this.props.accounts[i].name,
current: this.props.current_user === this.props.accounts[i].name
},
this.props.accounts[i])));
return (
<>
{items}
</>
);
}
}
AccountList.displayName = 'AccountList';
function log_unexpected_error(error) {
console.warn("Unexpected error", error);
}
PageAccounts.prototype = {
_init: function() {
this.id = "accounts";
this.permission = cockpit.permission({ admin: true });
this.permission.addEventListener("changed", () => this.update());
},
getTitle: function() {
return C_("page-title", "Accounts");
},
show: function() {
},
setup: function() {
$('#accounts-create').on('click', $.proxy(this, "create"));
},
enter: function() {
var self = this;
function parse_accounts(content) {
self.accounts = parse_passwd_content(content);
self.update();
}
this.handle_passwd = cockpit.file('/etc/passwd');
this.handle_passwd.read()
.done(parse_accounts)
.fail(log_unexpected_error);
this.handle_passwd.watch(parse_accounts);
},
leave: function() {
if (this.handle_passwd) {
this.handle_passwd.close();
this.handle_passwd = null;
}
},
update: function() {
if (!this.accounts)
return;
const current_user = this.permission.user;
this.accounts.sort(function (a, b) {
if (current_user && current_user.name === a.name) return -1;
else if (current_user && current_user.name === b.name) return 1;
else if (!a.gecos) return -1;
else if (!b.gecos) return 1;
else return a.gecos.localeCompare(b.gecos);
});
var accounts = this.accounts.filter(function(account) {
return !((account.uid < 1000 && account.uid !== 0) ||
account.shell.match(/^(\/usr)?\/sbin\/nologin/) ||
account.shell === '/bin/false');
});
ReactDOM.render(
React.createElement(AccountList, { accounts: accounts, current_user: this.permission.user ? this.permission.user.name : "" }),
document.getElementById('accounts-list')
);
},
create: function () {
PageAccountsCreate.accounts = this.accounts;
$('#accounts-create-dialog').modal('show');
},
go: function (user) {
cockpit.location.go([user]);
}
};
function PageAccounts() {
this._init();
}
PageAccountsCreate.prototype = {
_init: function() {
this.id = "accounts-create-dialog";
this.username_dirty = false;
},
show: function() {
},
setup: function() {
var self = this;
$('#accounts-create-cancel').on('click', $.proxy(this, "cancel"));
$('#accounts-create-create').on('click', $.proxy(this, "create"));
$('#accounts-create-dialog .check-passwords').on('keydown change', $.proxy(this, "validate"));
$('#accounts-create-real-name').on('input', $.proxy(this, "suggest_username"));
$('#accounts-create-user-name').on('input', function() { self.username_dirty = true });
},
enter: function() {
$('#accounts-create-user-name').val("");
$('#accounts-create-real-name').val("");
$('#accounts-create-pw1').val("");
$('#accounts-create-pw2').val("");
$('#accounts-create-locked').prop('checked', false);
$('#accounts-create-password-meter').removeClass("weak okay good excellent");
$("#accounts-create-dialog").dialog("failure", null);
this.username_dirty = false;
},
leave: function() {
},
validate: function() {
var ex;
var fails = [];
var pw = $('#accounts-create-pw1').val();
if ($('#accounts-create-pw2').val() != pw) {
ex = new Error(_("The passwords do not match"));
ex.target = "#accounts-create-pw2";
fails.push(ex);
}
if (!$('#accounts-create-user-name').val()) {
ex = new Error(_("No user name specified"));
ex.target = '#accounts-create-user-name';
fails.push(ex);
}
if (!$('#accounts-create-real-name').val()) {
ex = new Error(_("No real name specified"));
ex.target = '#accounts-create-real-name';
fails.push(ex);
}
/* The first check is immediately complete */
var dfd = cockpit.defer();
if (fails.length)
dfd.reject(fails);
else
dfd.resolve();
var promise_password = password_quality(pw)
.fail(function(ex) {
ex.target = "#accounts-create-pw2";
})
.always(function(arg) {
var strength = this.state() == "resolved" ? arg : "weak";
var meter = $("#accounts-create-password-meter")
.removeClass("weak okay good excellent");
if (pw)
meter.addClass(strength);
var message = $("#accounts-create-password-meter-message");
if (strength == "excellent") {
message.text(_("Excellent password"));
} else {
message.text("");
}
});
var promise_username = this.check_username()
.fail(function(ex) {
ex.target = "#accounts-create-user-name";
});
// Can't use Promise.all() here, because this promise is passed to
// dialog(), which expects a promise with a progress() method (see
// pkg/lib/patterns.js)
// eslint-disable-next-line cockpit/no-cockpit-all
return cockpit.all(dfd.promise(), promise_password, promise_username);
},
cancel: function() {
$('#accounts-create-dialog').modal('hide');
},
create: function() {
var tasks = [
() => {
const dfd = cockpit.defer();
cockpit.spawn(["/usr/sbin/useradd", "-D"])
.done(defaults => {
defaults.split("\n").forEach(item => {
if (item.indexOf("SHELL=") === 0) {
this.user_shell = item.split("=")[1] || "";
}
});
dfd.resolve();
})
.fail(dfd.resolve); // Don't fail if we cannot read defaults
return dfd.promise();
},
() => {
var prog = ["/usr/sbin/useradd", "--create-home", "-s", this.user_shell || "/bin/bash"];
if ($('#accounts-create-real-name').val()) {
prog.push('-c');
prog.push($('#accounts-create-real-name').val());
}
prog.push($('#accounts-create-user-name').val());
return cockpit.spawn(prog, { superuser: "require", err: "message" });
}
];
tasks.push(function change_passwd() {
return passwd_change($('#accounts-create-user-name').val(), $('#accounts-create-pw1').val());
});
if ($('#accounts-create-locked').prop('checked')) {
tasks.push(function adjust_locked() {
return cockpit.spawn([
"/usr/sbin/usermod",
$('#accounts-create-user-name').val(),
"--lock"
], { superuser: "require", err: "message" });
});
}
var promise = this.validate()
.fail(function(ex) {
$("#accounts-create-password-meter-message").hide();
$("#accounts-create-dialog").dialog("failure", ex);
})
.done(function() {
promise = chain(tasks);
$("#accounts-create-dialog").dialog("promise", promise);
});
$("#accounts-create-dialog").dialog("wait", promise);
},
is_valid_char_username: function(c) {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
},
check_username: function() {
var dfd = cockpit.defer();
var username = $('#accounts-create-user-name').val();
for (var i = 0; i < username.length; i++) {
if (!this.is_valid_char_username(username[i])) {
dfd.reject(new Error(
_("The user name can only consist of letters from a-z, digits, dots, dashes and underscores.")
));
return dfd.promise();
}
}
for (var k = 0; k < PageAccountsCreate.accounts.length; k++) {
if (PageAccountsCreate.accounts[k].name == username) {
dfd.reject(new Error(_("This user name already exists")));
return dfd.promise();
}
}
dfd.resolve();
return dfd.promise();
},
suggest_username: function() {
var self = this;
function remove_diacritics(str) {
var translate_table = {
a : '[àáâãäå]',
ae: 'æ',
c : '[čç]',
d : 'ď',
e : '[èéêë]',
i : '[íìïî]',
l : '[ĺľ]',
n : '[ňñ]',
o : '[òóôõö]',
oe: 'œ',
r : '[ŕř]',
s : 'š',
t : 'ť',
u : '[ùúůûűü]',
y : '[ýÿ]',
z : 'ž',
};
for (var i in translate_table)
str = str.replace(new RegExp(translate_table[i], 'g'), i);
for (var k = 0; k < str.length;) {
if (!self.is_valid_char_username(str[k]))
str = str.substr(0, k) + str.substr(k + 1);
else
k++;
}
return str;
}
function make_username(realname) {
var result = "";
var 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);
}
if (this.username_dirty)
return;
var username = make_username($('#accounts-create-real-name').val());
$('#accounts-create-user-name').val(username);
}
};
function PageAccountsCreate() {
this._init();
}
PageAccount.prototype = {
_init: function(user) {
this.id = "account-page";
this.section_id = "accounts";
this.roles = [];
this.role_template = $("#role-entry-tmpl").html();
mustache.parse(this.role_template);
this.keys_template = $("#authorized-keys-tmpl").html();
mustache.parse(this.keys_template);
this.authorized_keys = null;
this.user = user;
this.permission = cockpit.permission({ admin: true });
this.permission.addEventListener("changed", () => this.update_accounts_privileged());
},
getTitle: function() {
return C_("page-title", "Accounts");
},
update_accounts_privileged: function() {
$(".accounts-self-privileged").addClass("accounts-privileged");
$(".accounts-privileged:not('.accounts-current-account')").update_privileged(
this.permission, cockpit.format(
_("The user <b>$0</b> is not permitted to modify accounts"),
this.permission.user ? this.permission.user.name : '')
);
$(".accounts-privileged").find("input")
.attr('disabled', this.permission.allowed === false);
// not exactly a permission, but needs to be adjusted after the above
if (this.permission.allowed && !this.logged)
$('#account-logout').attr('disabled', true);
// enable fields for current account.
$(".accounts-current-account").update_privileged(
{ allowed: true }, ""
);
$(".accounts-current-account").find("input")
.attr('disabled', false);
if ($('#account-user-name').text() === 'root' && this.permission.allowed) {
$("#account-delete").update_privileged({ allowed: false },
_("Unable to delete root account"));
$("#account-real-name-wrapper").update_privileged({ allowed: false },
_("Unable to rename root account"));
$("#account-real-name").prop('disabled', true);
}
},
show: function() {
var self = this;
$("#account").toggle(!!self.account_id);
$("#account-failure").toggle(!self.account_id);
},
setup: function() {
$('#account .breadcrumb a').on("click", function() {
cockpit.location.go('/');
});
$('#account-real-name').on('change', $.proxy(this, "change_real_name"));
$('#account-real-name').on('keydown', $.proxy(this, "real_name_edited"));
$('#account-set-password').on('click', $.proxy(this, "set_password"));
$('#account-delete').on('click', $.proxy(this, "delete_account"));
$('#account-logout').on('click', $.proxy(this, "logout_account"));
$('#account-locked').on('change', $.proxy(this, "change_locked", true, null));
$('#add-authorized-key').on('click', $.proxy(this, "add_key"));
$('#add-authorized-key-dialog').on('hidden.bs.modal', function () {
$("#authorized-keys-text").val("");
});
},
setup_keys: function (user_name, home_dir) {
var self = this;
if (!self.authorized_keys) {
self.authorized_keys = authorized_keys.instance(user_name, home_dir);
$(self.authorized_keys).on("changed", function () {
self.update();
});
}
},
remove_key: function (ev) {
if (!this.authorized_keys)
return;
var key = $(ev.target).data("raw");
$(".account-remove-key").prop('disabled', true);
this.authorized_keys.remove_key(key)
.fail(show_unexpected_error)
.always(function () {
$(".account-remove-key").prop('disabled', false);
});
},
add_key: function () {
if (!this.authorized_keys) {
$("#add-authorized-key-dialog").modal('hide');
return;
}
var key = $("#authorized-keys-text").val();
var promise = this.authorized_keys.add_key(key);
$("#add-authorized-key-dialog").dialog("promise", promise);
},
get_user: function() {
var self = this;
function parse_user(content) {
var accounts = parse_passwd_content(content);
for (var i = 0; i < accounts.length; i++) {
if (accounts[i].name !== self.account_id)
continue;
self.account = accounts[i];
self.setup_keys(self.account.name, self.account.home);
self.update();
return;
}
/* no such account find, clear it */
self.account = null;
}
this.handle_passwd = cockpit.file('/etc/passwd');
self.handle_shadow = cockpit.file('/etc/shadow', { superuser: "try" });
var saw_shadow = false;
this.handle_passwd.read()
.done(parse_user)
.fail(log_unexpected_error);
this.handle_passwd.watch(function(content) {
parse_user(content);
if (!saw_shadow)
self.get_expire();
if (self.account)
self.get_locked();
});
self.handle_shadow.watch(function() {
saw_shadow = true;
self.get_expire();
});
},
get_roles: function() {
var self = this;
var role_groups = {
wheel: _("Server Administrator"),
sudo: _("Server Administrator"),
docker: _("Container Administrator"),
weldr: _("Image Builder")
};
function parse_groups(content) {
var groups = parse_group_content(content);
while (self.roles.length > 0)
self.roles.pop();
for (var i = 0; i < groups.length; i++) {
var name = groups[i].name;
if (role_groups[name]) {
self.roles.push({
name: name,
desc: role_groups[name],
id: groups[i].gid,
member: is_user_in_group(self.account_id, groups[i]),
});
}
}
$(self).triggerHandler("roles");
self.update();
}
this.handle_groups = cockpit.file('/etc/group');
this.handle_groups.read()
.done(parse_groups)
.fail(log_unexpected_error);
this.handle_groups.watch(parse_groups);
},
get_last_login: function() {
var self = this;
function parse_last_login(data) {
data = data.split('\n')[1]; // throw away header
if (data.length === 0) return null;
data = data.split(' '); // get last column - separated by spaces
if (data[data.length - 1].indexOf('**Never logged in**') > -1)
return null;
else
return new Date(data[data.length - 1]);
}
cockpit.spawn(["/usr/bin/lastlog", "-u", self.account_id], { environ: ["LC_ALL=C"] })
.done(function (data) {
self.lastLogin = parse_last_login(data);
self.update();
})
.fail(function() {
self.lastLogin = null;
self.update();
});
},
get_locked: function(update_display) {
update_display = typeof update_display !== 'undefined' ? update_display : true;
var dfd = cockpit.defer();
var self = this;
function parse_locked(content) {
var status = content.split(" ")[1];
// libuser uses "LK", shadow-utils use "L".
return status && (status == "LK" || status == "L");
}
cockpit.spawn(["/usr/bin/passwd", "-S", self.account_id], { environ: ["LC_ALL=C"], superuser: "require" })
.done(function(content) {
self.locked = parse_locked(content);
if (update_display)
self.update();
dfd.resolve(self.locked);
})
.fail(function(error) {
dfd.reject(error);
});
return dfd.promise();
},
get_logged: function() {
var self = this;
if (!self.account_id) {
self.logged = false;
self.update();
return;
}
function parse_logged(content) {
self.logged = content.length > 0;
if (!self.logged)
self.get_last_login();
else
self.update();
}
cockpit.spawn(["/usr/bin/w", "-sh", self.account_id])
.done(parse_logged)
.fail(log_unexpected_error);
},
get_expire: function() {
var self = this;
function parse_expire(data) {
var i, line;
data = data.split('\n');
var account_expiration = '';
var account_date = '';
var password_expiration = '';
var password_days = -1;
for (i = 0; i < data.length; i++) {
line = data[i].split(': ');
if (line[0] && line[0].indexOf("Password expires") === 0) {
if (line[1].indexOf("never") === 0) {
password_expiration = _("Never expire password");
} else if (line[1].indexOf("password must be changed") === 0) {
password_expiration = _("Password must be changed");
} else {
password_expiration = cockpit.format(_("Require password change on $0"), moment(line[1]).format('LL'));
}
} else if (line[0] && line[0].indexOf("Account expires") === 0) {
if (line[1].indexOf("never") === 0) {
account_expiration = _("Never lock account");
} else {
account_date = new Date(line[1] + " 12:00:00 UTC");
account_expiration = cockpit.format(_("Lock account on $0"), moment(line[1]).format('LL'));
}
} else if (line[0] && line[0].indexOf("Maximum number of days between password change") === 0) {
password_days = line[1];
}
}
$('#account-expiration-button').text(account_expiration);
$('#account-expiration-button').data('expire-date', account_date);
$('#password-expiration-button').text(password_expiration);
$('#password-expiration-button').data('expire-days', password_days);
self.update();
}
cockpit.spawn(["/usr/bin/chage", "-l", self.account_id],
{ environ: ["LC_ALL=C"], err: "message", superuser: "try" })
.done(function(data) {
parse_expire(data);
})
.fail(function(ex) {
parse_expire("");
});
},
enter: function(account_id) {
this.account_id = account_id;
this.roles_changed = false;
$("#account-real-name").removeAttr("data-dirty");
$('#password-reset-button').data('account-id', this.account_id);
$('#password-expiration-button').data('account-id', this.account_id);
$('#account-expiration-button').data('account-id', this.account_id);
this.get_user();
this.get_roles();
this.get_locked();
this.get_logged();
this.get_expire();
},
leave: function() {
if (this.handle_passwd) {
this.handle_passwd.close();
this.handle_passwd = null;
}
if (this.handle_groups) {
this.handle_groups.close();
this.handle_groups = null;
}
if (this.authorized_keys) {
$(this.authorized_keys).off();
this.authorized_keys.close();
this.authorized_keys = null;
}
$('#account-failure').prop("hidden", true);
},
update: function() {
if (this.account) {
$('#account').prop("hidden", false);
$('#account-failure').prop("hidden", true);
var name = $("#account-real-name");
var title_name = this.account.gecos;
if (title_name)
title_name = title_name.split(',')[0];
else
title_name = this.account.name;
$("#account-title").text(title_name);
if (!name.attr("data-dirty"))
$('#account-real-name').val(this.account.gecos);
$('#account-user-name').text(this.account.name);
if (this.logged)
$('#account-last-login').text(_("Logged In"));
else if (!this.lastLogin)
$('#account-last-login').text(_("Never"));
else
$('#account-last-login').text(moment(this.lastLogin).format('LLL'));
if (typeof this.locked != 'undefined') {
$('#account-locked').prop('checked', this.locked);
$('#account-locked').prop('disabled', false);
} else {
$('#account-locked').prop('disabled', true);
}
if (this.authorized_keys) {
var keys = this.authorized_keys.keys;
var state = this.authorized_keys.state;
var keys_html = mustache.render(this.keys_template, {
keys: keys,
empty: keys.length === 0 && state == "ready",
denied: state == "access-denied",
failed: state == "failed",
});
$('#account-authorized-keys-list').html(keys_html);
$(".account-remove-key")
.on("click", $.proxy(this, "remove_key"));
$('#account-authorized-keys').show();
} else {
$('#account-authorized-keys').hide();
}
if (this.account.uid !== 0) {
var html = mustache.render(this.role_template,
{ roles: this.roles, changed: this.roles_changed });
$('#account-change-roles-roles').html(html);
$('#account-roles').parents('tr')
.show();
$('#account-roles [data-toggle="tooltip"]')
.tooltip();
$("#account-change-roles-roles :input")
.on("change", $.proxy(this, "change_role"));
} else {
$('#account-roles')
.parents('tr')
.hide();
}
$('#account .breadcrumb .active')
.text(title_name);
// check accounts-self-privileged whether account is the same as currently logged in user
$(".accounts-self-privileged")
.toggleClass("accounts-current-account",
this.user.id == this.account.uid);
} else {
$('#account').hide();
$('#account-failure').prop("hidden", false);
$('#account-real-name').val("");
$('#account-user-name').text("");
$('#account-last-login').text("");
$('#account-locked').prop('checked', false);
$('#account-change-roles-roles').html("");
$('#account .breadcrumb .active').text("?");
}
this.update_accounts_privileged();
},
change_role: function(ev) {
var self = this;
var name = $(ev.target).data("name");
var id = $(ev.target).data("gid");
if (!name || !id || !this.account.name)
return;
var proc;
var checked = $(ev.target).prop('checked');
var input_elements =
$('#account button:not([disabled]), #account input:not([disabled]), #account a:not([disabled])');
input_elements.prop('disabled', true);
if (checked) {
proc = cockpit.spawn(["/usr/sbin/usermod", this.account.name, "-G", id, "-a"],
{ superuser: "require", err: "message" });
} else {
proc = cockpit.spawn(["/usr/bin/gpasswd", "-d", this.account.name, name],
{ superuser: "require", err: "message" });
}
proc.then(function(data) {
if (!data && checked)
data = "Added " + self.account.name + " to group " + name;
else if (!data && !checked)
data = "Removed " + self.account.name + " from group " + name;
console.log(data);
if (self.logged)
self.roles_changed = true;
self.update();
}, show_unexpected_error).finally(function() {
input_elements.prop('disabled', false);
});
},
real_name_edited: function() {
$("#account-real-name").attr("data-dirty", "true");
},
check_role_for_self_mod: function () {
return (this.account.name == this.user.name || !!this.permission.allowed);
},
change_real_name: function() {
var self = this;
var name = $("#account-real-name");
name.attr("data-dirty", "true");
if (!self.check_role_for_self_mod()) {
self.update();
return;
}
// TODO: unwanted chars check
var value = name.val();
var input_elements =
$('#account button:not([disabled]), #account input:not([disabled]), #account a:not([disabled])');
input_elements.prop('disabled', true);
cockpit.spawn(["/usr/sbin/usermod", self.account.name, "--comment", value],
{ superuser: "try", err: "message" })
.done(function(data) {
self.account.gecos = value;
self.update();
name.removeAttr("data-dirty");
})
.fail(show_unexpected_error)
.finally(function() {
input_elements.prop('disabled', false);
});
},
change_locked: function(verify_status, desired_lock_state) {
desired_lock_state = desired_lock_state !== null
? desired_lock_state : $('#account-locked').prop('checked');
var self = this;
var input_elements =
$('#account button:not([disabled]), #account input:not([disabled]), #account a:not([disabled])');
input_elements.prop('disabled', true);
cockpit.spawn(["/usr/sbin/usermod",
this.account.name,
desired_lock_state ? "--lock" : "--unlock"], { superuser: "require", err: "message" })
.done(function() {
self.get_locked(false)
.done(function(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
This seems to be fixed in fedora 23 (usermod catches the different locking behavior)
*/
if (verify_status && desired_lock_state !== locked) {
console.log("Account locked state doesn't match desired value, trying again.");
// only retry once to avoid uncontrolled recursion
self.change_locked(false, desired_lock_state);
} else {
self.update();
}
});
})
.fail(show_unexpected_error)
.finally(function() {
input_elements.prop('disabled', false);
});
},
set_password: function() {
if (!this.check_role_for_self_mod())
return;
PageAccountSetPassword.user_name = this.account.name;
/* TODO: get rid of this once monitoring /etc/shadow will be implemented */
PageAccountSetPassword.update_callback = $.proxy(this, "enter");
$('#account-set-password-dialog').modal('show');
},
delete_account: function() {
PageAccountConfirmDelete.user_name = this.account.name;
$('#account-confirm-delete-dialog').modal('show');
},
logout_account: function() {
var input_elements =
$('#account button:not([disabled]), #account input:not([disabled]), #account a:not([disabled])');
input_elements
.prop('disabled', true);
cockpit.spawn(["/usr/bin/loginctl", "terminate-user", this.account.name],
{ superuser: "try", err: "message" })
.done($.proxy(this, "get_logged"))
.fail(show_unexpected_error)
.finally(function() {
input_elements.prop('disabled', false);
});
},
};
function PageAccount(user) {
this._init(user);
}
PageAccountConfirmDelete.prototype = {
_init: function() {
this.id = "account-confirm-delete-dialog";
},
show: function() {
},
setup: function() {
$('#account-confirm-delete-apply').on('click', $.proxy(this, "apply"));
},
enter: function() {
$('#account-confirm-delete-files').prop('checked', false);
$('#account-confirm-delete-title').text(cockpit.format(_("Delete $0"), PageAccountConfirmDelete.user_name));
},
leave: function() {
},
apply: function() {
var prog = ["/usr/sbin/userdel"];
if ($('#account-confirm-delete-files').prop('checked'))
prog.push("-r");
prog.push(PageAccountConfirmDelete.user_name);
cockpit.spawn(prog, { superuser: "require", err: "message" })
.done(function () {
$('#account-confirm-delete-dialog').modal('hide');
cockpit.location.go("/");
})
.fail(show_unexpected_error);
}
};
function PageAccountConfirmDelete() {
this._init();
}
function AccountExpiration() {
$("#account-expiration").on("show.bs.modal", function(ev) {
var account_id = $(ev.relatedTarget).data("account-id");
$("#account-expiration").data("account-id", account_id);
/* Fill in initial dialog values */
var expire_date = $(ev.relatedTarget).data("expire-date");
$(expire_date ? '#account-expiration-expires' : "#account-expiration-never").prop('checked', true);
$('#account-expiration-input').val(expire_date ? expire_date.toISOString().substr(0, 10) : "");
$('#account-expiration-input').prop('disabled', !expire_date);
/* TRANSLATORS: This is split up and therefore cannot use ngettext plurals */
var parts = _("Lock account on $0").split("$0");
$("#account-expiration-before").text(parts[0]);
$("#account-expiration-after").text(parts[1]);
});
$('#account-expiration .pf-m-primary').on('click', function() {
var date, value;
var ex;
var promise = null;
/* Parse the dialog data and validate */
if ($('#account-expiration-expires').prop('checked')) {
value = $('#account-expiration-input').val();
if (!value) {
ex = new Error(_("Please specify an expiration date"));
ex.target = "#account-expiration-input";
} else {
date = new Date(value + "T12:00:00Z");
if (isNaN(date.getTime()) || date.getTime() < 0) {
ex = new Error(_("Invalid expiration date"));
ex.target = "#account-expiration-input";
}
}
if (ex)
promise = cockpit.reject(ex);
}
if ($('#account-expiration-never').prop('checked')) {
date = null;
promise = null;
}
/* Actually perform the action if valid */
var prog = ["/usr/sbin/usermod", "-e"];
var account_id = $("#account-expiration").data("account-id");
if (!promise) {
if (date)
prog.push(date.toISOString().substr(0, 10));
else
prog.push("");
prog.push(account_id);
promise = cockpit.spawn(prog, { superuser : true, err: "message" });
}
$("#account-expiration").dialog("promise", promise);
});
$('#account-expiration input').on('change', function() {
$('#account-expiration-input').prop('disabled', $("#account-expiration-never").prop('checked'));
});
$('#account-expiration-input').datepicker({
autoclose: true,
todayHighlight: true,
format: 'yyyy-mm-dd'
});
$('#account-expiration-input').on("show.bs.modal", function(ev) {
ev.stopPropagation();
});
}
function PasswordExpiration() {
var never = 99999;
$("#password-expiration").on("show.bs.modal", function(ev) {
var account_id = $(ev.relatedTarget).data("account-id");
$("#password-expiration").data("account-id", account_id);
/* Fill in initial dialog values */
var expire_days = parseInt($(ev.relatedTarget).data("expire-days"), 10);
if (isNaN(expire_days) || expire_days < 0 || expire_days >= never)
expire_days = null;
$(expire_days ? '#password-expiration-expires' : "#password-expiration-never").prop('checked', true);
$('#password-expiration-input').val(expire_days || "");
$('#password-expiration-input').prop('disabled', !expire_days);
/* TRANSLATORS: This is split up and therefore cannot use ngettext plurals */
var parts = _("Require password change every $0 days").split("$0");
$("#password-expiration-before").text(parts[0]);
$("#password-expiration-after").text(parts[1]);
});
$('#password-expiration .pf-m-primary').on('click', function() {
var days, ex;
var promise = null;
if ($('#password-expiration-expires').prop('checked'))
days = parseInt($('#password-expiration-input')
.val()
.trim(), 10);
if ($('#password-expiration-never').prop('checked'))
days = never;
if (isNaN(days) || days < 0) {
ex = new Error(_("Invalid number of days"));
ex.target = "#password-expiration-input";
promise = cockpit.reject(ex);
}
var account_id = $("#password-expiration").data("account-id");
if (!promise) {
promise = cockpit.spawn(["/usr/bin/passwd", "-x", String(days), account_id],
{ superuser: true, err: "message" });
}
$("#password-expiration").dialog("promise", promise);
});
$('#password-expiration input').on('change', function() {
$('#password-expiration-input').prop('disabled', $("#password-expiration-never").prop('checked'));
});
}
function PasswordReset() {
$("#password-reset").on("show.bs.modal", function(ev) {
var account_id = $(ev.relatedTarget).data("account-id");
$("#password-reset").data("account-id", account_id);
var msg = cockpit.format(_("The account '$0' will be forced to change their password on next login"),
account_id);
$("#password-reset .modal-body p").text(msg);
});
$("#password-reset .pf-m-danger").on("click", function() {
var account_id = $("#password-reset").data("account-id");
var promise = cockpit.spawn(["/usr/bin/passwd", "-e", account_id],
{ superuser : true, err: "message" });
$("#password-reset").dialog("promise", promise);
});
}
PageAccountSetPassword.prototype = {
_init: function(user) {
this.id = "account-set-password-dialog";
this.user = user;
},
show: function() {
if (this.user.id === 0 || this.user.name !== PageAccountSetPassword.user_name) {
$('#account-set-password-old')
.toggle(false);
$('#account-set-password-old').prev()
.toggle(false);
$('#account-set-password-pw1')
.focus();
} else {
$('#account-set-password-old')
.toggle(true);
$('#account-set-password-old').prev()
.toggle(true);
$('#account-set-password-old')
.focus();
}
},
setup: function() {
$('#account-set-password-apply').on('click', $.proxy(this, "apply"));
$('#account-set-password-dialog .check-passwords').on('keydown change', $.proxy(this, "validate"));
},
enter: function() {
$('#account-set-password-old').val("");
$('#account-set-password-pw1').val("");
$('#account-set-password-pw2').val("");
$('#account-set-password-meter').removeClass("weak okay good excellent");
$("#account-set-password-dialog").dialog("failure", null);
},
leave: function() {
},
validate: function() {
var ex;
var pw = $('#account-set-password-pw1').val();
if ($('#account-set-password-pw2').val() != pw) {
ex = new Error(_("The passwords do not match"));
ex.target = "#account-set-password-pw2";
}
var dfd = cockpit.defer();
if (ex)
dfd.reject(ex);
else
dfd.resolve();
var promise = password_quality(pw)
.fail(function(ex) {
ex.target = "#account-set-password-pw2";
})
.always(function(arg) {
var strength = (this.state() == "resolved") ? arg : "weak";
var meter = $("#account-set-password-meter")
.removeClass("weak okay good excellent");
if (pw)
meter.addClass(strength);
var message = $("#account-set-password-meter-message");
if (strength == "excellent") {
message.text(_("Excellent password"));
} else {
message.text("");
}
});
// Can't use Promise.all() here, because this promise is passed to
// dialog(), which expects a promise with a progress() method (see
// pkg/lib/patterns.js)
// eslint-disable-next-line cockpit/no-cockpit-all
return cockpit.all(dfd.promise(), promise);
},
apply: function() {
var self = this;
var promise = this.validate()
.done(function() {
var user = PageAccountSetPassword.user_name;
var password = $('#account-set-password-pw1').val();
if (self.user.name === user)
promise = passwd_self($('#account-set-password-old').val(), password);
else
promise = passwd_change(user, password);
$("#account-set-password-dialog").dialog("promise", promise);
})
.fail(function(ex) {
$("#account-set-password-meter-message").prop("hidden", true);
$("#account-set-password-dialog").dialog("failure", ex);
});
$("#account-set-password-dialog").dialog("wait", promise);
}
};
function PageAccountSetPassword(user) {
this._init(user);
}
/* INITIALIZATION AND NAVIGATION
*
* The code above still uses the legacy 'Page' abstraction for both
* pages and dialogs, and expects page.setup, page.enter, page.show,
* and page.leave to be called at the right times.
*
* We cater to this with a little compatability shim consisting of
* 'dialog_setup', 'page_show', and 'page_hide'.
*/
function show_error_dialog(title, message) {
if (message) {
$("#error-popup-title").text(title);
$("#error-popup-message").text(message);
} else {
$("#error-popup-title").text(_("Error"));
$("#error-popup-message").text(title);
}
$('.modal[role="dialog"]').modal('hide');
$('#error-popup').modal('show');
}
function show_unexpected_error(error) {
show_error_dialog(_("Unexpected error"), error.message || error);
}
function dialog_setup(d) {
d.setup();
$('#' + d.id)
.on('show.bs.modal', function () { d.enter() })
.on('shown.bs.modal', function () { d.show() })
.on('hidden.bs.modal', function () { d.leave() });
}
function page_show(p, arg) {
if (p._entered_)
p.leave();
p.enter(arg);
p._entered_ = true;
$('#' + p.id).prop("hidden", false);
p.show();
}
function page_hide(p) {
$('#' + p.id).prop("hidden", true);
if (p._entered_) {
p.leave();
p._entered_ = false;
}
}
function init() {
var overview_page;
var account_page;
cockpit.user().done(function (user) {
function navigate() {
var path = cockpit.location.path;
if (path.length === 0) {
page_hide(account_page);
page_show(overview_page);
} else if (path.length === 1) {
page_hide(overview_page);
page_show(account_page, path[0]);
} else { /* redirect */
console.warn("not a users location: " + path);
cockpit.location = '';
}
$("body").prop("hidden", false);
}
cockpit.translate();
overview_page = new PageAccounts();
overview_page.setup();
account_page = new PageAccount(user);
account_page.setup();
dialog_setup(new PageAccountsCreate());
dialog_setup(new PageAccountConfirmDelete());
dialog_setup(new PageAccountSetPassword(user));
AccountExpiration();
PasswordExpiration();
PasswordReset();
$(cockpit).on("locationchanged", navigate);
navigate();
});
}
$(init);