Port User Management dialog to React. Fixes #7345

This commit is contained in:
Akshay Joshi 2022-08-11 10:25:52 +05:30
parent 3766fa7f0b
commit 271b6d91fc
17 changed files with 751 additions and 1225 deletions

View File

@ -0,0 +1,27 @@
.. _change_ownership:
************************************
`Change Ownership Dialog`:index:
************************************
Use the *Change Ownership* dialog to change the ownership of the shared servers.
This dialog will appear if a user has been deleted from
:ref:`User Management <user_management>` and owned some shared servers.
Choose the user who will own the shared servers from the drop-down.
.. image:: images/change_ownership.png
:alt: Change ownership dialog
:align: center
Click the *Change* button to change the ownership.
The shared servers owned by the user will be deleted if the user is not
selected from the drop-down.
.. image:: images/change_ownership_info.png
:alt: Change ownership dialog
:align: center
Click the *Change* button to change the ownership; click *Close* to
exit the dialog.

View File

@ -35,6 +35,7 @@ Mode is pre-configured for security.
login login
mfa mfa
user_management user_management
change_ownership
change_user_password change_user_password
restore_locked_user restore_locked_user
ldap ldap

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -15,6 +15,7 @@ Housekeeping
************ ************
| `Issue #7344 <https://redmine.postgresql.org/issues/7344>`_ - Port Role Reassign dialog to React. | `Issue #7344 <https://redmine.postgresql.org/issues/7344>`_ - Port Role Reassign dialog to React.
| `Issue #7345 <https://redmine.postgresql.org/issues/7345>`_ - Port User Management dialog to React.
| `Issue #7462 <https://redmine.postgresql.org/issues/7462>`_ - Remove the SQL files for the unsupported versions of the database server. | `Issue #7462 <https://redmine.postgresql.org/issues/7462>`_ - Remove the SQL files for the unsupported versions of the database server.
| `Issue #7567 <https://redmine.postgresql.org/issues/7567>`_ - Port About dialog to React. | `Issue #7567 <https://redmine.postgresql.org/issues/7567>`_ - Port About dialog to React.
| `Issue #7568 <https://redmine.postgresql.org/issues/7568>`_ - Port change user password and 2FA dialog to React. | `Issue #7568 <https://redmine.postgresql.org/issues/7568>`_ - Port change user password and 2FA dialog to React.

View File

@ -35,12 +35,13 @@ To add a user, click the Add (+) button at the top right corner.
Provide information about the new pgAdmin role in the row: Provide information about the new pgAdmin role in the row:
* Use the drop-down list box next to *Authentication source* field to select the * Use the drop-down list box next to *Authentication source* field to select the
type of authentication that should be used for the user. If LDAP type of authentication that should be used for the user. If authentication
authentication is not enabled for pgAdmin, then *Authentication source* field source is only 'internal' then *Authentication source* field
is disabled. is disabled. Supported *Authentication source* are internal, ldap, kerberos,
oauth2 and webserver.
* Click in the *Username* field, and provide a username for the user. This field * Click in the *Username* field, and provide a username for the user. This field
is enabled only if you select *ldap* as authentication source. If you select is enabled only when you select authentication source except *internal*. If you
*internal* as authentication source, your email address is displayed in the select *internal* as authentication source, your email address is displayed in the
username field. username field.
* Click in the *Email* field, and provide an email address for the user. * Click in the *Email* field, and provide an email address for the user.
* Use the drop-down list box next to *Role* to select whether a user is an * Use the drop-down list box next to *Role* to select whether a user is an
@ -54,15 +55,19 @@ Provide information about the new pgAdmin role in the row:
active; the default is *Yes*. Use this switch to disable account activity active; the default is *Yes*. Use this switch to disable account activity
without deleting an account. without deleting an account.
* Use the *New password* field to provide the password associated with the user * Use the *New password* field to provide the password associated with the user
specified in the *Email* field. This field is disabled if you select *ldap* specified in the *Email* field. This field is disabled if you select any
as authentication source since LDAP password is not stored in the pgAdmin database. authentication source except *internal*.
* Re-enter the password in the *Confirm password* field. This field is disabled * Re-enter the password in the *Confirm password* field. This field is disabled
if you select *ldap* as authentication source. if you select *ldap* as authentication source.
* Move the *Locked* switch to the *True* position if you want to lock the account; * Move the *Locked* switch to the *True* position if you want to lock the account;
the default is *False*. This functionality is useful when a user is locked by trying unsuccessful login attempts. the default is *False*. This functionality is useful when a user is locked by
trying unsuccessful login attempts.
To discard a user, and revoke access to pgAdmin, click the trash icon to the To discard a user, and revoke access to pgAdmin, click the trash icon to the
left of the row and confirm deletion in the *Delete user?* dialog. left of the row and confirm deletion in the *Delete user?* dialog. If the user
has created some shared servers, then the :ref:`Change Ownership <change_ownership>`
dialog will appear to change the ownership of a shared server.
Users with the *Administrator* role are able to add, edit and remove pgAdmin Users with the *Administrator* role are able to add, edit and remove pgAdmin
users, but otherwise have the same capabilities as those with the *User* role. users, but otherwise have the same capabilities as those with the *User* role.

View File

@ -144,7 +144,7 @@ window.onload = function(e){
<ul class="dropdown-menu dropdown-menu-right" role="menu"> <ul class="dropdown-menu dropdown-menu-right" role="menu">
{% if auth_only_internal %} {% if auth_only_internal %}
<li> <li>
<a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.Browser.UserManagement.change_password( <a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.UserManagement.change_password(
'{{ url_for('browser.change_password') }}' '{{ url_for('browser.change_password') }}'
)"> )">
{{ _('Change Password') }} {{ _('Change Password') }}
@ -154,14 +154,14 @@ window.onload = function(e){
{% endif %} {% endif %}
{% if mfa_enabled is defined and mfa_enabled is true %} {% if mfa_enabled is defined and mfa_enabled is true %}
<li> <li>
<a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.Browser.UserManagement.show_mfa( <a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.UserManagement.show_mfa(
'{{ login_url("mfa.register", next_url="internal") }}' '{{ login_url("mfa.register", next_url="internal") }}'
)">{{ _('Two-Factor Authentication') }}</a> )">{{ _('Two-Factor Authentication') }}</a>
</li> </li>
<li class="dropdown-divider"></li> <li class="dropdown-divider"></li>
{% endif %} {% endif %}
{% if is_admin %} {% if is_admin %}
<li><a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.Browser.UserManagement.show_users()">{{ _('Users') }}</a></li> <li><a class="dropdown-item" href="#" role="menuitem" onclick="pgAdmin.UserManagement.show_users()">{{ _('Users') }}</a></li>
<li class="dropdown-divider"></li> <li class="dropdown-divider"></li>
{% endif %} {% endif %}
<li><a class="dropdown-item" role="menuitem" href="{{ logout_url }}">{{ _('Logout') }}</a></li> <li><a class="dropdown-item" role="menuitem" href="{{ logout_url }}">{{ _('Logout') }}</a></li>

View File

@ -301,7 +301,7 @@ export function showChangeOwnership() {
userList = arguments[1], userList = arguments[1],
noOfSharedServers = arguments[2], noOfSharedServers = arguments[2],
deletedUser = arguments[3], deletedUser = arguments[3],
destroyUserManagement = arguments[4]; deleteUserRow = arguments[4];
// Render Preferences component // Render Preferences component
Notify.showModal(title, (onClose) => { Notify.showModal(title, (onClose) => {
@ -314,24 +314,15 @@ export function showChangeOwnership() {
return new Promise((resolve, reject)=>{ return new Promise((resolve, reject)=>{
if (data.newUser == '') { if (data.newUser == '') {
api.delete(url_for('user_management.user', {uid: deletedUser['uid']})) deleteUserRow();
.then(() => { onClose();
Notify.success(gettext('User deleted.'));
onClose();
destroyUserManagement();
resolve();
})
.catch((err)=>{
Notify.error(err);
reject(err);
});
} else { } else {
let newData = {'new_owner': `${data.newUser}`, 'old_owner': `${deletedUser['uid']}`}; let newData = {'new_owner': `${data.newUser}`, 'old_owner': `${deletedUser['id']}`};
api.post(url_for('user_management.change_owner'), newData) api.post(url_for('user_management.change_owner'), newData)
.then(({data: respData})=>{ .then(({data: respData})=>{
Notify.success(gettext(respData.info)); Notify.success(gettext(respData.info));
onClose(); onClose();
destroyUserManagement(); deleteUserRow();
resolve(respData.data); resolve(respData.data);
}) })
.catch((err)=>{ .catch((err)=>{

View File

@ -17,7 +17,7 @@ import AddIcon from '@material-ui/icons/AddOutlined';
import { MappedCellControl } from './MappedControl'; import { MappedCellControl } from './MappedControl';
import EditRoundedIcon from '@material-ui/icons/EditRounded'; import EditRoundedIcon from '@material-ui/icons/EditRounded';
import DeleteRoundedIcon from '@material-ui/icons/DeleteRounded'; import DeleteRoundedIcon from '@material-ui/icons/DeleteRounded';
import { useTable, useFlexLayout, useResizeColumns, useSortBy, useExpanded } from 'react-table'; import { useTable, useFlexLayout, useResizeColumns, useSortBy, useExpanded, useGlobalFilter } from 'react-table';
import clsx from 'clsx'; import clsx from 'clsx';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import _ from 'lodash'; import _ from 'lodash';
@ -30,6 +30,7 @@ import { evalFunc } from 'sources/utils';
import { DepListenerContext } from './DepListener'; import { DepListenerContext } from './DepListener';
import { useIsMounted } from '../custom_hooks'; import { useIsMounted } from '../custom_hooks';
import Notify from '../helpers/Notifier'; import Notify from '../helpers/Notifier';
import { InputText } from '../components/FormComponents';
const useStyles = makeStyles((theme)=>({ const useStyles = makeStyles((theme)=>({
grid: { grid: {
@ -225,11 +226,26 @@ function DataTableRow({row, totalRows, isResizing, schema, schemaRef, accessPath
</div>, depsMap); </div>, depsMap);
} }
export function DataGridHeader({label, canAdd, onAddClick}) { export function DataGridHeader({label, canAdd, onAddClick, canSearch, onSearchTextChange}) {
const classes = useStyles(); const classes = useStyles();
const [searchText, setSearchText] = useState('');
return ( return (
<Box className={classes.gridHeader}> <Box className={classes.gridHeader}>
{ label &&
<Box className={classes.gridHeaderText}>{label}</Box> <Box className={classes.gridHeaderText}>{label}</Box>
}
{ canSearch &&
<Box className={classes.gridHeaderText} width={'100%'}>
<InputText value={searchText}
onChange={(value)=>{
onSearchTextChange(value);
setSearchText(value);
}}
placeholder={'Search'}>
</InputText>
</Box>
}
<Box className={classes.gridControls}> <Box className={classes.gridControls}>
{canAdd && <PgIconButton data-test="add-row" title={gettext('Add row')} onClick={onAddClick} icon={<AddIcon />} className={classes.gridControlsButton} />} {canAdd && <PgIconButton data-test="add-row" title={gettext('Add row')} onClick={onAddClick} icon={<AddIcon />} className={classes.gridControlsButton} />}
</Box> </Box>
@ -240,6 +256,8 @@ DataGridHeader.propTypes = {
label: PropTypes.string, label: PropTypes.string,
canAdd: PropTypes.bool, canAdd: PropTypes.bool,
onAddClick: PropTypes.func, onAddClick: PropTypes.func,
canSearch: PropTypes.bool,
onSearchTextChange: PropTypes.func,
}; };
export default function DataGridView({ export default function DataGridView({
@ -303,21 +321,27 @@ export default function DataGridView({
return ( return (
<PgIconButton data-test="delete-row" title={gettext('Delete row')} icon={<DeleteRoundedIcon fontSize="small" />} <PgIconButton data-test="delete-row" title={gettext('Delete row')} icon={<DeleteRoundedIcon fontSize="small" />}
onClick={()=>{ onClick={()=>{
Notify.confirm( const deleteRow = ()=> {
props.customDeleteTitle || gettext('Delete Row'), dataDispatch({
props.customDeleteMsg || gettext('Are you sure you wish to delete this row?'), type: SCHEMA_STATE_ACTIONS.DELETE_ROW,
function() { path: accessPath,
dataDispatch({ value: row.index,
type: SCHEMA_STATE_ACTIONS.DELETE_ROW, });
path: accessPath, return true;
value: row.index, };
});
return true; if (props.onDelete){
}, props.onDelete(row.original || {}, deleteRow);
function() { } else {
return true; Notify.confirm(
} props.customDeleteTitle || gettext('Delete Row'),
); props.customDeleteMsg || gettext('Are you sure you wish to delete this row?'),
deleteRow,
function() {
return true;
}
);
}
}} className={classes.gridRowButton} disabled={!canDeleteRow} /> }} className={classes.gridRowButton} disabled={!canDeleteRow} />
); );
} }
@ -425,6 +449,7 @@ export default function DataGridView({
}), []); }), []);
let tablePlugins = [ let tablePlugins = [
useGlobalFilter,
useFlexLayout, useFlexLayout,
useResizeColumns, useResizeColumns,
useSortBy, useSortBy,
@ -437,10 +462,11 @@ export default function DataGridView({
headerGroups, headerGroups,
rows, rows,
prepareRow, prepareRow,
setGlobalFilter,
} = useTable( } = useTable(
{ {
columns, columns,
data: value || [], data: value,
defaultColumn, defaultColumn,
manualSortBy: true, manualSortBy: true,
autoResetSortBy: false, autoResetSortBy: false,
@ -476,7 +502,12 @@ export default function DataGridView({
return ( return (
<Box className={containerClassName}> <Box className={containerClassName}>
<Box className={classes.grid}> <Box className={classes.grid}>
{(props.label || props.canAdd) && <DataGridHeader label={props.label} canAdd={props.canAdd} onAddClick={onAddClick} />} {(props.label || props.canAdd) && <DataGridHeader label={props.label} canAdd={props.canAdd} onAddClick={onAddClick}
canSearch={props.canSearch}
onSearchTextChange={(value)=>{
setGlobalFilter(value || undefined);
}}
/>}
<div {...getTableProps()} className={classes.table}> <div {...getTableProps()} className={classes.table}>
<DataTableHeader headerGroups={headerGroups} /> <DataTableHeader headerGroups={headerGroups} />
<div {...getTableBodyProps()} className={classes.tableContentWidth}> <div {...getTableBodyProps()} className={classes.tableContentWidth}>
@ -524,4 +555,6 @@ DataGridView.propTypes = {
]), ]),
customDeleteTitle: PropTypes.string, customDeleteTitle: PropTypes.string,
customDeleteMsg: PropTypes.string, customDeleteMsg: PropTypes.string,
canSearch: PropTypes.bool,
onDelete: PropTypes.func,
}; };

View File

@ -268,10 +268,10 @@ export default function FormView({
} }
const props = { const props = {
key: field.id, value: value[field.id], viewHelperProps: viewHelperProps, key: field.id, value: value[field.id] || [], viewHelperProps: viewHelperProps,
schema: field.schema, accessPath: accessPath.concat(field.id), dataDispatch: dataDispatch, schema: field.schema, accessPath: accessPath.concat(field.id), dataDispatch: dataDispatch,
containerClassName: classes.controlRow, ...field, canAdd: canAdd, canEdit: canEdit, canDelete: canDelete, containerClassName: classes.controlRow, ...field, canAdd: canAdd, canEdit: canEdit, canDelete: canDelete,
visible: visible, canAddRow: canAddRow, visible: visible, canAddRow: canAddRow, onDelete: field.onDelete, canSearch: field.canSearch
}; };
if(CustomControl) { if(CustomControl) {

View File

@ -155,7 +155,7 @@ function MappedCellControlBase({ cell, value, id, optionsLoaded, onCellChange, v
case 'text': case 'text':
return <InputText name={name} value={value} onChange={onTextChange} {...props}/>; return <InputText name={name} value={value} onChange={onTextChange} {...props}/>;
case 'password': case 'password':
return <InputText name={name} value={value} onChange={onTextChange} {...props}/>; return <InputText name={name} value={value} onChange={onTextChange} {...props} type='password'/>;
case 'select': case 'select':
return <InputSelect name={name} value={value} onChange={onTextChange} optionsLoaded={optionsLoadedRerender} {...props}/>; return <InputSelect name={name} value={value} onChange={onTextChange} optionsLoaded={optionsLoadedRerender} {...props}/>;
case 'switch': case 'switch':

View File

@ -886,7 +886,7 @@ function SchemaPropertiesView({
key={field.id} key={field.id}
viewHelperProps={viewHelperProps} viewHelperProps={viewHelperProps}
name={field.id} name={field.id}
value={origData[field.id]} value={origData[field.id] || []}
schema={field.schema} schema={field.schema}
accessPath={[field.id]} accessPath={[field.id]}
formErr={{}} formErr={{}}

View File

@ -10,10 +10,8 @@
"""Implements pgAdmin4 User Management Utility""" """Implements pgAdmin4 User Management Utility"""
import simplejson as json import simplejson as json
import re
from flask import render_template, request, \ from flask import render_template, request, \
url_for, Response, abort, current_app, session Response, abort, current_app, session
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_security import login_required, roles_required, current_user from flask_security import login_required, roles_required, current_user
from flask_security.utils import encrypt_password from flask_security.utils import encrypt_password
@ -25,7 +23,7 @@ from pgadmin.utils.ajax import make_response as ajax_response, \
make_json_response, bad_request, internal_server_error, forbidden make_json_response, bad_request, internal_server_error, forbidden
from pgadmin.utils.csrf import pgCSRFProtect from pgadmin.utils.csrf import pgCSRFProtect
from pgadmin.utils.constants import MIMETYPE_APP_JS, INTERNAL,\ from pgadmin.utils.constants import MIMETYPE_APP_JS, INTERNAL,\
SUPPORTED_AUTH_SOURCES, KERBEROS, LDAP SUPPORTED_AUTH_SOURCES
from pgadmin.utils.validation_utils import validate_email from pgadmin.utils.validation_utils import validate_email
from pgadmin.model import db, Role, User, UserPreference, Server, \ from pgadmin.model import db, Role, User, UserPreference, Server, \
ServerGroup, Process, Setting, roles_users, SharedServer ServerGroup, Process, Setting, roles_users, SharedServer
@ -59,12 +57,11 @@ class UserManagementModule(PgAdminModule):
""" """
return [ return [
'user_management.roles', 'user_management.role', 'user_management.roles', 'user_management.role',
'user_management.update_user', 'user_management.delete_user', 'user_management.users', 'user_management.user',
'user_management.create_user', 'user_management.users', current_app.login_manager.login_view,
'user_management.user', current_app.login_manager.login_view, 'user_management.auth_sources', 'user_management.change_owner',
'user_management.auth_sources', 'user_management.auth_sources',
'user_management.shared_servers', 'user_management.admin_users', 'user_management.shared_servers', 'user_management.admin_users',
'user_management.change_owner', 'user_management.save'
] ]
@ -74,55 +71,6 @@ blueprint = UserManagementModule(
) )
def validate_password(data, new_data):
"""
Check password new and confirm password match. If both passwords are not
match raise exception.
:param data: Data.
:param new_data: new data dict.
"""
if ('newPassword' in data and data['newPassword'] != "" and
'confirmPassword' in data and data['confirmPassword'] != ""):
if data['newPassword'] == data['confirmPassword']:
new_data['password'] = encrypt_password(data['newPassword'])
else:
raise InternalServerError(_("Passwords do not match."))
def validate_user(data):
new_data = dict()
validate_password(data, new_data)
if 'email' in data and data['email'] and data['email'] != "":
if validate_email(data['email']):
new_data['email'] = data['email']
else:
raise InternalServerError(_("Invalid email address."))
if 'role' in data and data['role'] != "":
new_data['roles'] = int(data['role'])
if 'active' in data and data['active'] != "":
new_data['active'] = data['active']
if 'username' in data and data['username'] != "":
new_data['username'] = data['username']
if 'auth_source' in data and data['auth_source'] != "":
new_data['auth_source'] = data['auth_source']
if 'locked' in data and type(data['locked']) == bool:
new_data['locked'] = data['locked']
if data['locked']:
new_data['login_attempts'] = config.MAX_LOGIN_ATTEMPTS
else:
new_data['login_attempts'] = 0
return new_data
@blueprint.route("/") @blueprint.route("/")
@login_required @login_required
def index(): def index():
@ -223,148 +171,6 @@ def user(uid):
) )
@blueprint.route('/user/', methods=['POST'], endpoint='create_user')
@roles_required('Administrator')
def create():
"""
Returns:
"""
data = request.form if request.form else json.loads(
request.data, encoding='utf-8'
)
status, res = create_user(data)
if not status:
return internal_server_error(errormsg=res)
return ajax_response(
response=res,
status=200
)
def _create_new_user(new_data):
"""
Create new user.
:param new_data: Data from user creation.
:return: Return new created user.
"""
auth_source = new_data['auth_source'] if 'auth_source' in new_data \
else INTERNAL
username = new_data['username'] if \
'username' in new_data and auth_source != \
INTERNAL else new_data['email']
email = new_data['email'] if 'email' in new_data else None
password = new_data['password'] if 'password' in new_data else None
usr = User(username=username,
email=email,
roles=new_data['roles'],
active=new_data['active'],
password=password,
auth_source=auth_source)
db.session.add(usr)
db.session.commit()
# Add default server group for new user.
server_group = ServerGroup(user_id=usr.id, name="Servers")
db.session.add(server_group)
db.session.commit()
return usr
def create_user(data):
if 'auth_source' in data and data['auth_source'] != \
INTERNAL:
req_params = ('username', 'role', 'active', 'auth_source')
else:
req_params = ('email', 'role', 'active', 'newPassword',
'confirmPassword')
for f in req_params:
if f in data and data[f] != '':
continue
else:
return False, _("Missing field: '{0}'").format(f)
try:
new_data = validate_user(data)
if 'roles' in new_data:
new_data['roles'] = [Role.query.get(new_data['roles'])]
except Exception as e:
return False, str(e)
try:
usr = _create_new_user(new_data)
except Exception as e:
return False, str(e)
return True, {
'id': usr.id,
'username': usr.username,
'email': usr.email,
'active': usr.active,
'role': usr.roles[0].id,
'locked': usr.locked
}
@blueprint.route(
'/user/<int:uid>', methods=['DELETE'], endpoint='delete_user'
)
@roles_required('Administrator')
def delete(uid):
"""
Args:
uid:
Returns:
"""
usr = User.query.get(uid)
if not usr:
abort(404)
try:
server_groups = ServerGroup.query.filter_by(user_id=uid).all()
sg = [server_group.id for server_group in server_groups]
Setting.query.filter_by(user_id=uid).delete()
UserPreference.query.filter_by(uid=uid).delete()
Server.query.filter_by(user_id=uid).delete()
ServerGroup.query.filter_by(user_id=uid).delete()
Process.query.filter_by(user_id=uid).delete()
# Delete Shared servers for current user.
SharedServer.query.filter_by(user_id=uid).delete()
SharedServer.query.filter(SharedServer.servergroup_id.in_(sg)).delete(
synchronize_session=False)
# Finally delete user
db.session.delete(usr)
db.session.commit()
return make_json_response(
success=1,
info=_("User deleted."),
data={}
)
except Exception as e:
return internal_server_error(errormsg=str(e))
@blueprint.route('/change_owner', methods=['POST'], endpoint='change_owner') @blueprint.route('/change_owner', methods=['POST'], endpoint='change_owner')
@roles_required('Administrator') @roles_required('Administrator')
def change_owner(): def change_owner():
@ -430,9 +236,6 @@ def change_owner():
server_group.user_id = data['new_owner'] server_group.user_id = data['new_owner']
db.session.commit() db.session.commit()
# Delete old owner records.
delete(data['old_owner'])
return make_json_response( return make_json_response(
success=1, success=1,
info=_("Owner changed successfully."), info=_("Owner changed successfully."),
@ -537,72 +340,6 @@ def admin_users(uid=None):
) )
@blueprint.route('/user/<int:uid>', methods=['PUT'], endpoint='update_user')
@roles_required('Administrator')
def update(uid):
"""
Args:
uid:
Returns:
"""
usr = User.query.get(uid)
if not usr:
abort(404)
data = request.form if request.form else json.loads(
request.data, encoding='utf-8'
)
# Username and email can not be changed for internal users
if usr.auth_source == INTERNAL:
non_editable_params = ('username', 'email')
for f in non_editable_params:
if f in data:
return forbidden(
errmsg=_(
"'{0}' is not allowed to modify."
).format(f)
)
try:
new_data = validate_user(data)
if 'roles' in new_data:
new_data['roles'] = [Role.query.get(new_data['roles'])]
except Exception as e:
return bad_request(errormsg=_(str(e)))
try:
for k, v in new_data.items():
setattr(usr, k, v)
db.session.commit()
res = {'id': usr.id,
'username': usr.username,
'email': usr.email,
'active': usr.active,
'role': usr.roles[0].id,
'auth_source': usr.auth_source,
'locked': usr.locked
}
return ajax_response(
response=res,
status=200
)
except Exception as e:
return internal_server_error(errormsg=str(e))
@blueprint.route( @blueprint.route(
'/role/', methods=['GET'], defaults={'rid': None}, endpoint='roles' '/role/', methods=['GET'], defaults={'rid': None}, endpoint='roles'
) )
@ -650,3 +387,221 @@ def auth_sources():
response=sources, response=sources,
status=200 status=200
) )
@blueprint.route('/save', methods=['POST'], endpoint='save')
@roles_required('Administrator')
def save():
"""
This function is used to add/update/delete users.
"""
data = request.form if request.form else json.loads(
request.data, encoding='utf-8'
)
try:
# Create Users
if 'added' in data:
for item in data['added']:
status, res = create_user(item)
if not status:
return internal_server_error(errormsg=res)
# Modify Users
if 'changed' in data:
for item in data['changed']:
status, res = update_user(item['id'], item)
if not status:
return internal_server_error(errormsg=res)
# Delete Users
if 'deleted' in data:
for item in data['deleted']:
status, res = delete_user(item['id'])
if not status:
return internal_server_error(errormsg=res)
except Exception as e:
return internal_server_error(errormsg=str(e))
return ajax_response(
status=200
)
def validate_password(data, new_data):
"""
Check password new and confirm password match. If both passwords are not
match raise exception.
:param data: Data.
:param new_data: new data dict.
"""
if ('newPassword' in data and data['newPassword'] != "" and
'confirmPassword' in data and data['confirmPassword'] != ""):
if data['newPassword'] == data['confirmPassword']:
new_data['password'] = encrypt_password(data['newPassword'])
else:
raise InternalServerError(_("Passwords do not match."))
def validate_user(data):
new_data = dict()
validate_password(data, new_data)
if 'email' in data and data['email'] and data['email'] != "":
if validate_email(data['email']):
new_data['email'] = data['email']
else:
raise InternalServerError(_("Invalid email address."))
if 'role' in data and data['role'] != "":
new_data['roles'] = int(data['role'])
if 'active' in data and data['active'] != "":
new_data['active'] = data['active']
if 'username' in data and data['username'] != "":
new_data['username'] = data['username']
if 'auth_source' in data and data['auth_source'] != "":
new_data['auth_source'] = data['auth_source']
if 'locked' in data and type(data['locked']) == bool:
new_data['locked'] = data['locked']
if data['locked']:
new_data['login_attempts'] = config.MAX_LOGIN_ATTEMPTS
else:
new_data['login_attempts'] = 0
return new_data
def _create_new_user(new_data):
"""
Create new user.
:param new_data: Data from user creation.
:return: Return new created user.
"""
auth_source = new_data['auth_source'] if 'auth_source' in new_data \
else INTERNAL
username = new_data['username'] if \
'username' in new_data and auth_source != \
INTERNAL else new_data['email']
email = new_data['email'] if 'email' in new_data else None
password = new_data['password'] if 'password' in new_data else None
usr = User(username=username,
email=email,
roles=new_data['roles'],
active=new_data['active'],
password=password,
auth_source=auth_source)
db.session.add(usr)
db.session.commit()
# Add default server group for new user.
server_group = ServerGroup(user_id=usr.id, name="Servers")
db.session.add(server_group)
db.session.commit()
def create_user(data):
if 'auth_source' in data and data['auth_source'] != \
INTERNAL:
req_params = ('username', 'role', 'active', 'auth_source')
else:
req_params = ('email', 'role', 'active', 'newPassword',
'confirmPassword')
for f in req_params:
if f in data and data[f] != '':
continue
else:
return False, _("Missing field: '{0}'").format(f)
try:
new_data = validate_user(data)
if 'roles' in new_data:
new_data['roles'] = [Role.query.get(new_data['roles'])]
except Exception as e:
return False, str(e)
try:
_create_new_user(new_data)
except Exception as e:
return False, str(e)
return True, ''
def update_user(uid, data):
"""
This function is used to update the users.
"""
usr = User.query.get(uid)
if not usr:
return False, _("Unable to update user '{0}'").format(uid)
# Username and email can not be changed for internal users
if usr.auth_source == INTERNAL:
non_editable_params = ('username', 'email')
for f in non_editable_params:
if f in data:
return False, _("'{0}' is not allowed to modify.").format(f)
try:
new_data = validate_user(data)
if 'roles' in new_data:
new_data['roles'] = [Role.query.get(new_data['roles'])]
except Exception as e:
return False, str(e)
try:
for k, v in new_data.items():
setattr(usr, k, v)
db.session.commit()
except Exception as e:
return False, str(e)
return True, ''
def delete_user(uid):
"""
This function is used to delete the users
"""
usr = User.query.get(uid)
if not usr:
return False, _("Unable to update user '{0}'").format(uid)
try:
server_groups = ServerGroup.query.filter_by(user_id=uid).all()
sg = [server_group.id for server_group in server_groups]
Setting.query.filter_by(user_id=uid).delete()
UserPreference.query.filter_by(uid=uid).delete()
Server.query.filter_by(user_id=uid).delete()
ServerGroup.query.filter_by(user_id=uid).delete()
Process.query.filter_by(user_id=uid).delete()
# Delete Shared servers for current user.
SharedServer.query.filter_by(user_id=uid).delete()
SharedServer.query.filter(SharedServer.servergroup_id.in_(sg)).delete(
synchronize_session=False)
# Finally delete user
db.session.delete(usr)
db.session.commit()
except Exception as e:
return False, str(e)
return True, ''

View File

@ -0,0 +1,381 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2022, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import ReactDOM from 'react-dom';
import { makeStyles } from '@material-ui/core';
import SchemaView from '../../../../static/js/SchemaView';
import BaseUISchema from '../../../../static/js/SchemaView/base_schema.ui';
import pgAdmin from 'sources/pgadmin';
import Theme from 'sources/Theme';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import PropTypes from 'prop-types';
import getApiInstance from '../../../../static/js/api_instance';
import authConstant from 'pgadmin.browser.constants';
import current_user from 'pgadmin.user_management.current_user';
import { isEmptyString } from '../../../../static/js/validators';
import Notify from '../../../../static/js/helpers/Notifier';
import { showChangeOwnership } from '../../../../static/js/Dialogs/index';
class UserManagementCollection extends BaseUISchema {
constructor(authSources, roleOptions) {
super({
id: undefined,
username: undefined,
email: undefined,
active: true,
role: '2',
newPassword: undefined,
confirmPassword: undefined,
auth_source: authConstant['INTERNAL']
});
this.authOnlyInternal = (current_user['auth_sources'].length == 1 &&
current_user['auth_sources'].includes(authConstant['INTERNAL'])) ? true : false;
this.authSources = authSources;
this.roleOptions = roleOptions;
}
get idAttribute() {
return 'id';
}
isUserNameEnabled(state) {
if (this.authOnlyInternal || state.auth_source == authConstant['INTERNAL']) {
return false;
}
return true;
}
isEditable(state) {
return state.id != current_user['id'];
}
get baseFields() {
let obj = this;
return [
{
id: 'auth_source', label: gettext('Authentication source'), cell: 'select',
options: obj.authSources, minWidth: 110, width: 110,
controlProps: {
allowClear: false,
openOnEnter: false,
first_empty: false,
},
visible: function() {
if (obj.authOnlyInternal)
return false;
return true;
},
editable: function(state) {
return (obj.isNew(state) && !obj.authOnlyInternal);
}
}, {
id: 'username', label: gettext('Username'), cell: 'text',
minWidth: 90, width: 90,
deps: ['auth_source'],
depChange: (state)=>{
if (obj.isUserNameEnabled(state) && obj.isNew(state) && !isEmptyString(obj.username)) {
state.username = undefined;
}
},
editable: (state)=> {
return obj.isUserNameEnabled(state);
}
}, {
id: 'email', label: gettext('Email'), cell: 'text',
minWidth: 90, width: 90, deps: ['id'],
editable: (state)=> {
if (obj.isNew(state))
return true;
if (obj.isEditable(state) && state.auth_source != authConstant['INTERNAL'])
return true;
return false;
}
}, {
id: 'role', label: gettext('Role'), cell: 'select',
options: obj.roleOptions, minWidth: 95, width: 95,
controlProps: {
allowClear: false,
openOnEnter: false,
first_empty: false,
},
editable: (state)=> {
return obj.isEditable(state);
}
}, {
id: 'active', label: gettext('Active'), cell: 'switch', width: 60, disableResizing: true,
editable: (state)=> {
return obj.isEditable(state);
}
}, {
id: 'newPassword', label: gettext('New password'), cell: 'password',
minWidth: 90, width: 90, deps: ['auth_source'],
editable: (state)=> {
return obj.isEditable(state) && state.auth_source == authConstant['INTERNAL'];
}
}, {
id: 'confirmPassword', label: gettext('Confirm password'), cell: 'password',
minWidth: 90, width: 90, deps: ['auth_source'],
editable: (state)=> {
return obj.isEditable(state) && state.auth_source == authConstant['INTERNAL'];
}
}, {
id: 'locked', label: gettext('Locked'), cell: 'switch', width: 60, disableResizing: true,
editable: (state)=> {
return obj.isEditable(state);
}
}
];
}
validate(state, setError) {
let msg = undefined;
let obj = this;
if (obj.isUserNameEnabled(state) && isEmptyString(state.username)) {
msg = gettext('Username cannot be empty.');
setError('username', msg);
return true;
} else {
setError('username', null);
}
if (state.auth_source == authConstant['INTERNAL']) {
let email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (isEmptyString(state.email)) {
msg = gettext('Email cannot be empty.');
setError('email', msg);
return true;
} else if (!email_filter.test(state.email)) {
msg = gettext('Invalid email address: %s.', state.email);
setError('email', msg);
return true;
} else {
setError('email', null);
}
// TODO: Check for duplicate email address errmsg = gettext('The email address %s already exists.'
if (obj.isNew(state) && isEmptyString(state.newPassword)) {
msg = gettext('Password cannot be empty for user %s.', state.email);
setError('newPassword', msg);
return true;
} else if (state.newPassword?.length < 6) {
msg = gettext('Password must be at least 6 characters for user %s.', state.email);
setError('newPassword', msg);
return true;
} else {
setError('newPassword', null);
}
if (obj.isNew(state) && isEmptyString(state.confirmPassword)) {
msg = gettext('Confirm Password cannot be empty for user %s.', state.email);
setError('confirmPassword', msg);
return true;
} else {
setError('confirmPassword', null);
}
if (state.newPassword !== state.confirmPassword) {
msg = gettext('Passwords do not match for user %s.', state.email);
setError('confirmPassword', msg);
return true;
} else {
setError('confirmPassword', null);
}
}
return false;
}
}
class UserManagementSchema extends BaseUISchema {
constructor(authSources, roleOptions) {
super();
this.userManagementCollObj = new UserManagementCollection(authSources, roleOptions);
}
deleteUser(deleteRow) {
Notify.confirm(
gettext('Delete user?'),
gettext('Are you sure you wish to delete this user?'),
deleteRow,
function() {
return true;
}
);
}
get baseFields() {
let obj = this;
const api = getApiInstance();
return [
{
id: 'userManagement', label: '', type: 'collection', schema: obj.userManagementCollObj,
canAdd: true, canDelete: true, isFullTab: true, group: 'temp_user',
canDeleteRow: (row)=>{
if (row['id'] == current_user['id'])
return false;
return true;
},
onDelete: (row, deleteRow)=> {
let deletedUser = {'id': row['id'], 'name': !isEmptyString(row['email']) ? row['email'] : row['username']};
api.get(url_for('user_management.shared_servers', {'uid': row['id']}))
.then((res)=>{
if (res.data?.data?.shared_servers > 0) {
api.get(url_for('user_management.admin_users', {'uid': row['id']}))
.then((result)=>{
showChangeOwnership(gettext('Change ownership'),
result?.data?.data?.result?.data,
res?.data?.data?.shared_servers,
deletedUser,
deleteRow
);
})
.catch((err)=>{
Notify.error(err);
});
} else {
obj.deleteUser(deleteRow);
}
})
.catch((err)=>{
Notify.error(err);
obj.deleteUser(deleteRow);
});
},
canSearch: true
},
];
}
}
const useStyles = makeStyles((theme)=>({
root: {
...theme.mixins.tabPanel,
padding: 0,
},
}));
function UserManagementDialog({onClose}) {
const classes = useStyles();
const [authSources, setAuthSources] = React.useState([]);
const [roles, setRoles] = React.useState([]);
const api = getApiInstance();
React.useEffect(async ()=>{
try {
api.get(url_for('user_management.auth_sources'))
.then(res=>{
setAuthSources(res.data);
})
.catch((err)=>{
Notify.error(err);
});
api.get(url_for('user_management.roles'))
.then(res=>{
setRoles(res.data);
})
.catch((err)=>{
Notify.error(err);
});
} catch (error) {
Notify.error(error);
}
}, []);
const onSaveClick = (_isNew, changeData)=>{
return new Promise((resolve, reject)=>{
try {
api.post(url_for('user_management.save'), changeData['userManagement'])
.then(()=>{
Notify.success('Users Saved Successfully');
})
.catch((err)=>{
Notify.error(err);
});
resolve();
onClose();
} catch (error) {
reject(error);
}
});
};
const authSourcesOptions = authSources.map((m)=>({
label: m.label,
value: m.value,
}));
if(authSourcesOptions.length <= 0) {
return <></>;
}
const roleOptions = roles.map((m)=>({
label: m.name,
value: m.id,
}));
if(roleOptions.length <= 0) {
return <></>;
}
const onDialogHelp = () => {
window.open(url_for('help.static', { 'filename': 'user_management.html' }), 'pgadmin_help');
};
return <SchemaView
formType={'dialog'}
getInitData={()=>{ return new Promise((resolve, reject)=>{
api.get(url_for('user_management.users'))
.then((res)=>{
resolve({userManagement:res.data});
})
.catch((err)=>{
reject(err);
});
}); }}
schema={new UserManagementSchema(authSourcesOptions, roleOptions)}
viewHelperProps={{
mode: 'edit',
}}
onSave={onSaveClick}
onClose={onClose}
onHelp={onDialogHelp}
hasSQL={false}
disableSqlHelp={true}
isTabView={false}
formClassName={classes.root}
/>;
}
UserManagementDialog.propTypes = {
onClose: PropTypes.func
};
export default function showUserManagement() {
pgAdmin.Browser.Node.registerUtilityPanel();
let panel = pgAdmin.Browser.Node.addUtilityPanel(980, pgAdmin.Browser.stdH.md),
j = panel.$container.find('.obj_properties').first();
panel.title(gettext('User Management'));
const onClose = ()=> {
ReactDOM.unmountComponentAtNode(j[0]);
panel.close();
};
ReactDOM.render(
<Theme>
<UserManagementDialog
onClose={onClose}
/>
</Theme>, j[0]);
}

View File

@ -7,914 +7,46 @@
// //
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
import Notify from '../../../../static/js/helpers/Notifier'; import pgAdmin from 'sources/pgadmin';
import { showChangeOwnership, showUrlDialog } from '../../../../static/js/Dialogs/index'; import gettext from 'sources/gettext';
import { showUrlDialog } from '../../../../static/js/Dialogs/index';
import showUserManagement from './UserManagementDialog';
define([
'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'pgadmin.alertifyjs',
'pgadmin.browser', 'backbone', 'backgrid', 'backform', 'pgadmin.browser.node', 'pgadmin.backform',
'pgadmin.user_management.current_user', 'sources/utils', 'pgadmin.browser.constants',
'backgrid.select.all', 'backgrid.filter',
], function(
gettext, url_for, $, _, alertify, pgBrowser, Backbone, Backgrid, Backform,
pgNode, pgBackform, userInfo, commonUtils, pgConst,
) {
// if module is already initialized, refer to that. class UserManagement {
if (pgBrowser.UserManagement) { static instance;
return pgBrowser.UserManagement;
static getInstance(...args) {
if (!UserManagement.instance) {
UserManagement.instance = new UserManagement(...args);
}
return UserManagement.instance;
} }
var USERURL = url_for('user_management.users'), init() {
ROLEURL = url_for('user_management.roles'), if (this.initialized)
SOURCEURL = url_for('user_management.auth_sources'), return;
DEFAULT_AUTH_SOURCE = pgConst['INTERNAL'], this.initialized = true;
LDAP = pgConst['LDAP'], }
KERBEROS = pgConst['KERBEROS'],
OAUTH2 = pgConst['OAUTH2'], // This is a callback function to show change user dialog.
AUTH_ONLY_INTERNAL = (userInfo['auth_sources'].length == 1 && userInfo['auth_sources'].includes(DEFAULT_AUTH_SOURCE)) ? true : false, change_password(url) {
userFilter = function(collection) { showUrlDialog(gettext('Change Password'), url, 'change_user_password.html', undefined, pgAdmin.Browser.stdH.lg);
return (new Backgrid.Extension.ClientSideFilter({ }
collection: collection,
placeholder: gettext('Filter by email'), // This is a callback function to show 2FA dialog.
// How long to wait after typing has stopped before searching can start show_mfa(url) {
wait: 150, showUrlDialog(gettext('Authentication'), url, 'mfa.html', 1200, 680);
})); }
};
// This is a callback function to show user management dialog.
// Integer Cell for Columns Length and Precision show_users() {
var PasswordDepCell = Backgrid.Extension.PasswordDepCell = showUserManagement();
Backgrid.Extension.PasswordCell.extend({ }
initialize: function() { }
Backgrid.Extension.PasswordCell.prototype.initialize.apply(this, arguments);
Backgrid.Extension.DependentCell.prototype.initialize.apply(this, arguments); pgAdmin.UserManagement = UserManagement.getInstance();
},
dependentChanged: function () { module.exports = {
this.$el.empty(); UserManagement: UserManagement,
this.render(); };
var model = this.model,
column = this.column,
editable = this.column.get('editable'),
is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
if (is_editable){ this.$el.addClass('editable'); }
else { this.$el.removeClass('editable'); }
this.delegateEvents();
return this;
},
render: function() {
Backgrid.NumberCell.prototype.render.apply(this, arguments);
var model = this.model,
column = this.column,
editable = this.column.get('editable'),
is_editable = _.isFunction(editable) ? !!editable.apply(column, [model]) : !!editable;
if (is_editable){ this.$el.addClass('editable'); }
else { this.$el.removeClass('editable'); }
return this;
},
remove: Backgrid.Extension.DependentCell.prototype.remove,
});
pgBrowser.UserManagement = {
init: function() {
if (this.initialized)
return;
this.initialized = true;
return this;
},
// Callback to draw change password Dialog.
change_password: function(url) {
showUrlDialog(gettext('Change Password'), url, 'change_user_password.html', undefined, pgBrowser.stdH.lg);
},
show_mfa: function(url) {
showUrlDialog(gettext('Authentication'), url, 'mfa.html', 1200, 680);
},
is_editable: function(m) {
if (m instanceof Backbone.Collection) {
return true;
}
return (m.get('id') != userInfo['id']);
},
// Callback to draw User Management Dialog.
show_users: function() {
if (!userInfo['is_admin']) return;
var self = this,
Roles = [],
Sources = [];
var UserModel = pgBrowser.Node.Model.extend({
idAttribute: 'id',
urlRoot: USERURL,
defaults: {
id: undefined,
username: undefined,
email: undefined,
active: true,
role: '2',
newPassword: undefined,
confirmPassword: undefined,
auth_source: DEFAULT_AUTH_SOURCE,
authOnlyInternal: AUTH_ONLY_INTERNAL,
},
schema: [{
id: 'auth_source',
label: gettext('Authentication source'),
type: 'text',
control: 'Select2',
url: url_for('user_management.auth_sources'),
cellHeaderClasses: 'width_percent_30',
visible: function(m) {
if (m.get('authOnlyInternal')) return false;
return true;
},
disabled: false,
cell: 'Select2',
select2: {
allowClear: false,
openOnEnter: false,
first_empty: false,
},
options: function() {
return Sources;
},
editable: function(m) {
if (m instanceof Backbone.Collection) {
return true;
}
return (m.isNew() && !m.get('authOnlyInternal'));
},
}, {
id: 'username',
label: gettext('Username'),
type: 'text',
cell: Backgrid.Extension.StringDepCell,
cellHeaderClasses: 'width_percent_30',
deps: ['auth_source'],
editable: function(m) {
if (m.get('authOnlyInternal') || m.get('auth_source') == DEFAULT_AUTH_SOURCE) {
if (m.isNew() && m.get('username') != undefined && m.get('username') != '') {
setTimeout( function() {
m.set('username', undefined);
}, 10);
}
return false;
}
return true;
},
disabled: false,
}, {
id: 'email',
label: gettext('Email'),
type: 'text',
cell: Backgrid.Extension.StringDepCell,
cellHeaderClasses: 'width_percent_30',
deps: ['id'],
editable: function(m) {
if (!m.get('authOnlyInternal')) return true;
if (m instanceof Backbone.Collection) {
return false;
}
// Disable email edit for existing user.
if (m.isNew()) {
return true;
}
return false;
},
}, {
id: 'role',
label: gettext('Role'),
type: 'text',
control: 'Select2',
cellHeaderClasses: 'width_percent_20',
cell: 'select2',
select2: {
allowClear: false,
openOnEnter: false,
first_empty: false,
},
options: function(controlOrCell) {
var options = [];
if (controlOrCell instanceof Backform.Control) {
// This is be backform select2 control
_.each(Roles, function(role) {
options.push({
label: role.name,
value: role.id.toString(),
});
});
} else {
// This must be backgrid select2 cell
_.each(Roles, function(role) {
options.push([role.name, role.id.toString()]);
});
}
return options;
},
editable: function(m) {
return self.is_editable(m);
},
}, {
id: 'active',
label: gettext('Active'),
type: 'switch',
cell: 'switch',
cellHeaderClasses: 'width_percent_10',
sortable: false,
editable: function(m) {
return self.is_editable(m);
},
}, {
id: 'newPassword',
label: gettext('New password'),
type: 'password',
disabled: false,
control: 'input',
cell: PasswordDepCell,
cellHeaderClasses: 'width_percent_20',
deps: ['auth_source'],
sortable: false,
editable: function(m) {
return (m.get('auth_source') == DEFAULT_AUTH_SOURCE);
},
}, {
id: 'confirmPassword',
label: gettext('Confirm password'),
type: 'password',
disabled: false,
control: 'input',
cell: PasswordDepCell,
cellHeaderClasses: 'width_percent_20',
deps: ['auth_source'],
sortable: false,
editable: function(m) {
return (m.get('auth_source') == DEFAULT_AUTH_SOURCE);
},
},{
id: 'locked',
label: gettext('Locked'),
type: 'switch',
cell: 'switch',
disabled: false,
sortable: false,
editable: function (m){
return (m.get('id') != userInfo['id']);
},
}],
validate: function() {
var errmsg = null,
changedAttrs = this.changed || {},
email_filter = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (this.get('auth_source') == DEFAULT_AUTH_SOURCE && ('email' in changedAttrs || !this.isNew()) && (_.isUndefined(this.get('email')) ||
_.isNull(this.get('email')) ||
String(this.get('email')).replace(/^\s+|\s+$/g, '') == '')) {
errmsg = gettext('Email address cannot be empty.');
this.errorModel.set('email', errmsg);
return errmsg;
} else if (!!this.get('email') && !email_filter.test(this.get('email'))) {
errmsg = gettext('Invalid email address: %s.',
this.get('email')
);
this.errorModel.set('email', errmsg);
return errmsg;
} else if (!!this.get('email') && this.collection.nonFilter.where({
'email': this.get('email'), 'auth_source': DEFAULT_AUTH_SOURCE,
}).length > 1) {
errmsg = gettext('The email address %s already exists.',
this.get('email')
);
this.errorModel.set('email', errmsg);
return errmsg;
} else {
this.errorModel.unset('email');
}
if ('role' in changedAttrs && (_.isUndefined(this.get('role')) ||
_.isNull(this.get('role')) ||
String(this.get('role')).replace(/^\s+|\s+$/g, '') == '')) {
errmsg = gettext('Role cannot be empty for user %s.',
(this.get('email') || '')
);
this.errorModel.set('role', errmsg);
return errmsg;
} else {
this.errorModel.unset('role');
}
if (this.get('auth_source') == DEFAULT_AUTH_SOURCE) {
if (this.isNew()) {
// Password is compulsory for new user.
if ('newPassword' in changedAttrs && (_.isUndefined(this.get('newPassword')) ||
_.isNull(this.get('newPassword')) ||
this.get('newPassword') == '')) {
errmsg = gettext('Password cannot be empty for user %s.',
(this.get('email') || '')
);
this.errorModel.set('newPassword', errmsg);
return errmsg;
} else if (!_.isUndefined(this.get('newPassword')) &&
!_.isNull(this.get('newPassword')) &&
this.get('newPassword').length < 6) {
errmsg = gettext('Password must be at least 6 characters for user %s.',
(this.get('email') || '')
);
this.errorModel.set('newPassword', errmsg);
return errmsg;
} else {
this.errorModel.unset('newPassword');
}
if ('confirmPassword' in changedAttrs && (_.isUndefined(this.get('confirmPassword')) ||
_.isNull(this.get('confirmPassword')) ||
this.get('confirmPassword') == '')) {
errmsg = gettext('Confirm Password cannot be empty for user %s.',
(this.get('email') || '')
);
this.errorModel.set('confirmPassword', errmsg);
return errmsg;
} else {
this.errorModel.unset('confirmPassword');
}
if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
this.get('newPassword') != this.get('confirmPassword')) {
errmsg = gettext('Passwords do not match for user %s.',
(this.get('email') || '')
);
this.errorModel.set('confirmPassword', errmsg);
return errmsg;
} else {
this.errorModel.unset('confirmPassword');
}
} else {
if ((_.isUndefined(this.get('newPassword')) || _.isNull(this.get('newPassword')) ||
this.get('newPassword') == '') &&
(_.isUndefined(this.get('confirmPassword')) || _.isNull(this.get('confirmPassword')) ||
this.get('confirmPassword') == '')) {
this.errorModel.unset('newPassword');
if (this.get('newPassword') == '') {
this.set({
'newPassword': undefined,
});
}
this.errorModel.unset('confirmPassword');
if (this.get('confirmPassword') == '') {
this.set({
'confirmPassword': undefined,
});
}
} else if (!_.isUndefined(this.get('newPassword')) &&
!_.isNull(this.get('newPassword')) &&
!this.get('newPassword') == '' &&
this.get('newPassword').length < 6) {
errmsg = gettext('Password must be at least 6 characters for user %s.',
(this.get('email') || '')
);
this.errorModel.set('newPassword', errmsg);
return errmsg;
} else if (_.isUndefined(this.get('confirmPassword')) ||
_.isNull(this.get('confirmPassword')) ||
this.get('confirmPassword') == '') {
errmsg = gettext('Confirm Password cannot be empty for user %s.',
(this.get('email') || '')
);
this.errorModel.set('confirmPassword', errmsg);
return errmsg;
} else if (!!this.get('newPassword') && !!this.get('confirmPassword') &&
this.get('newPassword') != this.get('confirmPassword')) {
errmsg = gettext('Passwords do not match for user %s.',
(this.get('email') || '')
);
this.errorModel.set('confirmPassword', errmsg);
return errmsg;
} else {
this.errorModel.unset('newPassword');
this.errorModel.unset('confirmPassword');
}
}
} else {
if (!!this.get('username') && (this.collection.nonFilter.where({
'username': this.get('username'), 'auth_source': LDAP,
}).length > 1) || (this.collection.nonFilter.where({
'username': this.get('username'), 'auth_source': KERBEROS,
}).length > 1) || (this.collection.nonFilter.where({
'username': this.get('username'), 'auth_source': OAUTH2,
}).length > 1)) {
errmsg = gettext('The username %s already exists.',
this.get('username')
);
this.errorModel.set('username', errmsg);
return errmsg;
}
}
return null;
},
}),
gridSchema = Backform.generateGridColumnsFromModel(
null, UserModel, 'edit'),
deleteUserCell = Backgrid.Extension.DeleteCell.extend({
raiseError: function() {
Notify.error(
gettext('Error during deleting user.')
);
},
changeOwnership: function(res, uid) {
let self = this,
url = url_for('user_management.admin_users', {'uid': uid});
const destroyUserManagement = ()=>{
alertify.UserManagement().destroy();
};
$.ajax({
url: url,
headers: {
'Cache-Control' : 'no-cache',
},
}).done(function (result) {
showChangeOwnership(gettext('Change ownership'),
result.data.result.data,
res['data'].shared_servers,
{uid: uid, name: self.model.get('username')},
destroyUserManagement
);
}).fail(function(e) {
let msg = '';
if(e.status == 404) {
msg = 'Unable to find url.';
} else {
msg = e.responseJSON.errormsg;
}
Notify.error(msg);
});
},
deleteUser: function() {
let self = this;
Notify.confirm(
gettext('Delete user?'),
gettext('Are you sure you wish to delete this user?'),
function() {
self.model.destroy({
wait: true,
success: function() {
Notify.success(gettext('User deleted.'));
},
error: self.raiseError,
});
},
function() {
return true;
}
);
},
deleteRow: function(e) {
var self = this;
e.preventDefault();
if (self.model.get('id') == userInfo['id']) {
Notify.alert(
gettext('Cannot delete user.'),
gettext('Cannot delete currently logged in user.'),
function() {
return true;
}
);
return true;
}
// We will check if row is deletable or not
var canDeleteRow = (!_.isUndefined(this.column.get('canDeleteRow')) &&
_.isFunction(this.column.get('canDeleteRow'))) ?
Backgrid.callByNeed(this.column.get('canDeleteRow'),
this.column, this.model) : true;
if (canDeleteRow) {
if (self.model.isNew()) {
self.model.destroy();
} else {
if(self.model.get('role') == 1){
$.ajax({
url: url_for('user_management.shared_servers', {'uid': self.model.get('id'),
}),
method: 'GET',
async: false,
})
.done(function(res) {
if(res['data'].shared_servers > 0) {
self.changeOwnership(res, self.model.get('id'));
} else {
self.deleteUser();
}
})
.fail(function() {
self.deleteUser();
});
} else {
self.deleteUser();
}
}
} else {
Notify.alert(
gettext('Error'),
gettext('This user cannot be deleted.'),
function() {
return true;
}
);
}
},
});
gridSchema.columns.unshift({
name: 'pg-backform-delete',
label: '',
cell: deleteUserCell,
editable: false,
cell_priority: -1,
canDeleteRow: true,
});
// Users Management dialog code here
if (!alertify.UserManagement) {
alertify.dialog('UserManagement', function factory() {
return {
main: function(title) {
this.set('title', title);
},
build: function() {
alertify.pgDialogBuild.apply(this);
},
setup: function() {
return {
buttons: [{
text: '',
key: 112,
className: 'btn btn-primary-icon pull-left fa fa-question pg-alertify-icon-button',
attrs: {
name: 'dialog_help',
type: 'button',
label: gettext('Users'),
url: url_for(
'help.static', {
'filename': 'user_management.html',
}),
},
}, {
text: gettext('Close'),
key: 27,
className: 'btn btn-secondary fa fa-lg fa-times pg-alertify-button user_management_pg-alertify-button',
attrs: {
name: 'close',
type: 'button',
},
}],
// Set options for dialog
options: {
title: gettext('User Management'),
//disable both padding and overflow control.
padding: !1,
overflow: !1,
modal: false,
resizable: true,
maximizable: true,
pinnable: false,
closableByDimmer: false,
closable: false,
},
};
},
hooks: {
// Triggered when the dialog is closed
onclose: function() {
if (this.view) {
// clear our backform model/view
this.view.remove({
data: true,
internal: true,
silent: true,
});
this.$content.remove();
}
},
},
prepare: function() {
var footerTpl = _.template([
'<div class="pg-prop-footer" style="visibility:hidden;">',
' <div class="pg-prop-status-bar">',
' <div class="error-in-footer"> ',
' <div class="d-flex px-2 py-1"> ',
' <div class="pr-2"> ',
' <i class="fa fa-exclamation-triangle text-danger" aria-hidden="true"></i> ',
' </div> ',
' <div class="alert-text" role="status"></div> ',
' <div class="ml-auto close-error-bar"> ',
' <a class="close-error fa fa-times text-danger"></a> ',
' </div> ',
' </div> ',
' </div> ',
' </div>',
'</div>',
].join('\n')),
$statusBar = $(footerTpl()),
UserRow = Backgrid.Row.extend({
userInvalidClass: 'bg-user-invalid',
initialize: function() {
Backgrid.Row.prototype.initialize.apply(this, arguments);
this.listenTo(this.model, 'pgadmin:user:invalid', this.userInvalid);
this.listenTo(this.model, 'pgadmin:user:valid', this.userValid);
},
userInvalid: function() {
$(this.el).removeClass('new');
$(this.el).addClass(this.userInvalidClass);
},
userValid: function() {
$(this.el).removeClass(this.userInvalidClass);
},
}),
UserCollection = Backbone.Collection.extend({
model: UserModel,
url: USERURL,
initialize: function() {
Backbone.Collection.prototype.initialize.apply(this, arguments);
var self = this;
self.changedUser = null;
self.invalidUsers = {};
self.nonFilter = this;
self.on('add', self.onModelAdd);
self.on('remove', self.onModelRemove);
self.on('pgadmin-session:model:invalid', function(msg, m) {
self.invalidUsers[m.cid] = msg;
m.trigger('pgadmin:user:invalid', m);
$statusBar.find('.alert-text').html(msg);
$statusBar.css('visibility', 'visible');
});
self.on('pgadmin-session:model:valid', function(m) {
delete self.invalidUsers[m.cid];
m.trigger('pgadmin:user:valid', m);
this.updateErrorMsg();
this.saveUser(m);
});
},
onModelAdd: function(m) {
// Start tracking changes.
m.startNewSession();
},
onModelRemove: function(m) {
delete this.invalidUsers[m.cid];
this.updateErrorMsg();
},
updateErrorMsg: function() {
var self = this,
msg = null;
for (var key in self.invalidUsers) {
msg = self.invalidUsers[key];
if (msg) {
break;
}
}
if (msg) {
$statusBar.find('.alert-text').html(msg);
$statusBar.css('visibility', 'visible');
} else {
$statusBar.find('.alert-text').empty();
$statusBar.css('visibility', 'hidden');
}
},
saveUser: function(m) {
var d = m.toJSON(true);
if((m.isNew() && (m.get('auth_source') == LDAP || m.get('auth_source') == KERBEROS || m.get('auth_source') == OAUTH2) && (!m.get('username') || !m.get('auth_source') || !m.get('role')))
|| (m.isNew() && m.get('auth_source') == DEFAULT_AUTH_SOURCE && (!m.get('email') || !m.get('role') ||
!m.get('newPassword') || !m.get('confirmPassword') || m.get('newPassword') != m.get('confirmPassword')))
|| (!m.isNew() && m.get('newPassword') != m.get('confirmPassword'))) {
// For old user password change is in progress and user model is valid but admin has not added
// both the passwords so return without saving.
return false;
}
if (m.sessChanged() && d && !_.isEmpty(d)) {
m.stopSession();
m.save({}, {
attrs: d,
wait: true,
success: function() {
// User created/updated on server now start new session for this user.
let temp_auth_sources = m.get('auth_source');
m.set({
'newPassword': undefined,
'confirmPassword': undefined,
'auth_source': undefined,
});
// It's a heck to re-render the Auth Source control.
m.set({
'auth_source': temp_auth_sources,
});
m.startNewSession();
Notify.success(gettext('User \'%s\' saved.',
m.get('username')
));
},
error: function(res, jqxhr) {
m.startNewSession();
Notify.error(
gettext('Error saving user: \'%s\'',
jqxhr.responseJSON.errormsg
)
);
},
});
}
},
}),
userCollection = this.userCollection = new UserCollection(),
header =
`<div class="navtab-inline-controls pgadmin-controls">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text fa fa-search" id="labelSearch"></span>
</div>
<input type="search" class="form-control" id="txtGridSearch" placeholder="` + gettext('Search') + '" aria-label="' + gettext('Search') + `" aria-describedby="labelSearch" />
</div>
<button id="btn_add" type="button" class="btn btn-secondary btn-navtab-inline add" title="` + gettext('Add') + `">
<span class="fa fa-plus "></span>
</button>
</div>
</div>
</div>`,
headerTpl = _.template(header),
data = {
canAdd: true,
add_title: gettext('Add new user'),
},
$gridBody = $('<div></div>', {
class: 'user_container flex-grow-1',
});
$.ajax({
url: ROLEURL,
method: 'GET',
async: false,
})
.done(function(res) {
Roles = res;
})
.fail(function() {
setTimeout(function() {
Notify.alert(
gettext('Error'),
gettext('Cannot load user roles.')
);
}, 100);
});
$.ajax({
url: SOURCEURL,
method: 'GET',
async: false,
})
.done(function(res) {
Sources = res;
})
.fail(function() {
setTimeout(function() {
Notify.alert(
gettext('Error'),
gettext('Cannot load user Sources.')
);
}, 100);
});
var view = this.view = new Backgrid.Grid({
row: UserRow,
columns: gridSchema.columns,
collection: userCollection,
className: 'backgrid table table-bordered table-noouter-border table-bottom-border table-hover',
});
$gridBody.append(view.render().$el[0]);
this.$content = $('<div class=\'user_management object subnode subnode-noouter-border d-flex flex-column\'></div>').append(
headerTpl(data)).append($gridBody).append($statusBar);
this.elements.content.appendChild(this.$content[0]);
// Render Search Filter
userCollection.nonFilter = userFilter(userCollection).setCustomSearchBox($('#txtGridSearch')).shadowCollection;
userCollection.fetch();
this.$content.find('a.close-error').on('click',() => {
$statusBar.find('.alert-text').empty();
$statusBar.css('visibility', 'hidden');
});
this.$content.find('button.add').first().on('click',(e) => {
e.preventDefault();
// There should be only one empty row.
let anyNew = false;
for(const [idx, model] of userCollection.models.entries()) {
if(model.isNew()) {
let row = view.body.rows[idx].$el;
row.addClass('new');
$(row).pgMakeVisible('backgrid');
$(row).find('.email').trigger('click');
anyNew = true;
}
}
if(!anyNew) {
$(view.body.$el.find($('tr.new'))).removeClass('new');
var m = new(UserModel)(null, {
handler: userCollection,
top: userCollection,
collection: userCollection,
});
userCollection.add(m);
var newRow = view.body.rows[userCollection.indexOf(m)].$el;
newRow.addClass('new');
$(newRow).pgMakeVisible('backgrid');
$(newRow).find('.email').trigger('click');
}
return false;
});
commonUtils.findAndSetFocus(this.$content);
},
callback: function(e) {
if (e.button.element.name == 'dialog_help') {
e.cancel = true;
pgBrowser.showHelp(e.button.element.name, e.button.element.getAttribute('url'),
null, null);
return;
}
if (e.button.element.name == 'close') {
var self = this;
if (!_.all(this.userCollection.pluck('id')) || !_.isEmpty(this.userCollection.invalidUsers)) {
e.cancel = true;
Notify.confirm(
gettext('Discard unsaved changes?'),
gettext('Are you sure you want to close the dialog? Any unsaved changes will be lost.'),
function() {
self.close();
return true;
},
function() {
// Do nothing.
return true;
}
);
}
}
},
};
});
}
alertify.UserManagement(true).resizeTo(pgBrowser.stdW.lg, pgBrowser.stdH.md);
},
};
return pgBrowser.UserManagement;
});