Open user management in a separate tab instead of a dialog to enhance UI/UX. #8574

pull/8598/head
Aditya Toshniwal 2025-03-25 12:33:49 +05:30 committed by GitHub
parent 213be44e29
commit 9ab451e163
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 785 additions and 517 deletions

View File

@ -156,10 +156,10 @@ define('pgadmin.browser.utils',
{% endif %}
{% if is_admin %}
{
label: '{{ _('Users') }}',
label: '{{ _('User Management') }}',
type: 'normal',
callback: ()=>{
pgAdmin.UserManagement.show_users()
pgAdmin.UserManagement.launchUserManagement()
}
},
{

View File

@ -206,6 +206,12 @@ basicSettings = createTheme(basicSettings, {
height: '100%',
boxSizing: 'border-box',
},
adornedStart: {
paddingLeft: basicSettings.spacing(0.75),
},
inputAdornedStart: {
paddingLeft: '2px',
},
adornedEnd: {
paddingRight: basicSettings.spacing(0.75),
},
@ -523,7 +529,7 @@ function getFinalTheme(baseTheme) {
},
inputSizeSmall: {
height: '16px', // + 12px of padding = 28px;
}
},
}
},
MuiSelect: {

View File

@ -441,11 +441,11 @@ export function getCheckboxHeaderCell({title}) {
return Cell;
}
export function getEditCell({isDisabled, title}) {
export function getEditCell({isDisabled, title, onClick}) {
const Cell = ({ row }) => {
return <PgIconButton data-test="expand-row" title={title} icon={<EditRoundedIcon fontSize="small" />} className='pgrt-cell-button'
onClick={()=>{
row.toggleExpanded();
onClick ? onClick(row) : row.toggleExpanded();
}} disabled={isDisabled?.(row)}
/>;
};

View File

@ -37,6 +37,7 @@ import gettext from 'sources/gettext';
import EmptyPanelMessage from './EmptyPanelMessage';
import { InputText } from './FormComponents';
import { PgReactTable, PgReactTableBody, PgReactTableCell, PgReactTableHeader, PgReactTableRow, PgReactTableRowContent, PgReactTableRowExpandContent, getCheckboxCell, getCheckboxHeaderCell } from './PgReactTableStyled';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
const ROW_HEIGHT = 30;
@ -334,6 +335,7 @@ export default function PgTable({ caveTable = true, tableNoBorder = true, tableN
onChange={(val) => {
setSearchVal(val);
}}
startAdornment={<SearchRoundedIcon />}
/>
</Box>
</Box>}

View File

@ -67,7 +67,7 @@ class UserManagementModule(PgAdminModule):
current_app.login_manager.login_view,
'user_management.auth_sources', 'user_management.change_owner',
'user_management.shared_servers', 'user_management.admin_users',
'user_management.save'
'user_management.save', 'user_management.save_id'
]
@ -168,7 +168,8 @@ def user(uid):
'active': u.active,
'role': u.roles[0].id,
'auth_source': u.auth_source,
'locked': u.locked
'locked': u.locked,
'canDrop': u.id != current_user.id
})
res = users_data
@ -337,7 +338,6 @@ def admin_users(uid=None):
return make_json_response(
success=1,
info=_("No shared servers found"),
data={
'status': 'success',
'msg': 'Admin user list',
@ -398,36 +398,32 @@ def auth_sources():
@blueprint.route('/save', methods=['POST'], endpoint='save')
@blueprint.route('/save/<int:id>', methods=['DELETE'], endpoint='save_id')
@roles_required('Administrator')
def save():
def save(id=None):
"""
This function is used to add/update/delete users.
"""
if request.method == 'DELETE':
status, res = delete_user(id)
if not status:
return internal_server_error(errormsg=res)
return ajax_response(
status=200
)
data = request.form if request.form else json.loads(
request.data
)
try:
# 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)
# 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)
except Exception as e:
return internal_server_error(errormsg=str(e))
if 'id' not in data:
status, res = create_user(data)
else:
status, res = update_user(data['id'], data)
if not status:
return internal_server_error(errormsg=res)
return ajax_response(
status=200
@ -468,9 +464,25 @@ def validate_password(data, new_data):
raise InternalServerError(_("Passwords do not match."))
def validate_unique_user(data):
if 'username' not in data:
return
exist_users = User.query.filter_by(
username=data['username'],
auth_source=data['auth_source']
).count()
if exist_users != 0:
raise InternalServerError(_("User email/username must be unique "
"for an authentication source."))
def validate_user(data):
new_data = dict()
validate_unique_user(data)
validate_password(data, new_data)
if 'email' in data and data['email'] and data['email'] != "":
@ -508,20 +520,12 @@ def _create_new_user(new_data):
: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,
usr = User(username=new_data['username'],
email=new_data['email'],
roles=new_data['roles'],
active=new_data['active'],
password=password,
auth_source=auth_source)
password=new_data['password'],
auth_source=new_data['auth_source'])
db.session.add(usr)
db.session.commit()
# Add default server group for new user.
@ -544,8 +548,18 @@ def create_user(data):
else:
return False, _("Missing field: '{0}'").format(f)
data['auth_source'] = data['auth_source'] if 'auth_source' in data \
else INTERNAL
data['username'] = data['username'] if \
'username' in data and data['auth_source'] != \
INTERNAL else data['email']
data['email'] = data['email'] if 'email' in data else None
data['password'] = data['password'] if 'password' in data else None
try:
new_data = validate_user(data)
new_data['password'] = new_data['password']\
if 'password' in new_data else None
if 'roles' in new_data:
new_data['roles'] = [Role.query.get(new_data['roles'])]
@ -588,7 +602,7 @@ def update_user(uid, data):
if 'roles' in new_data:
new_data['roles'] = [Role.query.get(new_data['roles'])]
except Exception as e:
return False, str(e.description)
return False, str(e)
try:
for k, v in new_data.items():

View File

@ -0,0 +1,55 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { Box, styled, Tab, Tabs } from '@mui/material';
import TabPanel from '../../../../static/js/components/TabPanel';
import Users from './Users';
const Root = styled('div')(({theme}) => ({
height: '100%',
background: theme.palette.grey[400],
display: 'flex',
flexDirection: 'column',
padding: '8px',
'& .Component-panel': {
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
...theme.mixins.panelBorder.all,
}
}));
export default function Component() {
const [tabValue, setTabValue] = React.useState(0);
return (
<Root>
<Box className='Component-panel'>
<Box>
<Tabs
value={tabValue}
onChange={(_e, selTabValue) => {
setTabValue(selTabValue);
}}
variant="scrollable"
scrollButtons="auto"
action={(ref)=>ref?.updateIndicator()}
>
<Tab label="Users" />
</Tabs>
</Box>
<TabPanel value={tabValue} index={0}>
<Users />
</TabPanel>
</Box>
</Root>
);
}

View File

@ -0,0 +1,249 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useMemo } from 'react';
import SchemaView from '../../../../static/js/SchemaView';
import BaseUISchema from '../../../../static/js/SchemaView/base_schema.ui';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import getApiInstance, { parseApiError } from '../../../../static/js/api_instance';
import {AUTH_METHODS} from 'pgadmin.browser.constants';
import current_user from 'pgadmin.user_management.current_user';
import { isEmptyString } from '../../../../static/js/validators';
import ErrorBoundary from '../../../../static/js/helpers/ErrorBoundary';
import PropTypes from 'prop-types';
import { usePgAdmin } from '../../../../static/js/PgAdminProvider';
class UserSchema extends BaseUISchema {
constructor(options, pgAdmin) {
super({
auth_source: 'internal',
role: 1,
active: true,
refreshBrowserTree: false
});
this.options = options;
this.pgAdmin = pgAdmin;
this.authOnlyInternal = (current_user['auth_sources'].length == 1 &&
current_user['auth_sources'].includes(AUTH_METHODS['INTERNAL']));
}
deleteUser(deleteRow) {
this.pgAdmin.Browser.notifier.confirm(
gettext('Delete user?'),
gettext('Are you sure you wish to delete this user?'),
deleteRow,
function() {
return true;
}
);
}
isUserNameEnabled(state) {
return this.isNew(state) && state.auth_source != AUTH_METHODS['INTERNAL'];
}
isNotCurrentUser(state) {
return state.id != current_user['id'];
}
get baseFields() {
let obj = this;
return [
{
id: 'auth_source', label: gettext('Authentication source'),
type: (state) => {
return {
type: 'select',
options: () => {
if (obj.isNew(state)) {
return Promise.resolve(obj.options.authSources.filter((s) => current_user['auth_sources'].includes(s.value)));
}
return Promise.resolve(obj.options.authSources);
},
optionsReloadBasis: obj.isNew(state)
};
},
controlProps: {
allowClear: false,
openOnEnter: false,
},
readonly: function (state) {
return !obj.isNew(state) || obj.authOnlyInternal;
}
}, {
id: 'username', label: gettext('Username'), type: 'text',
deps: ['auth_source'],
depChange: (state) => {
if (!obj.isUserNameEnabled(state)) {
return { username: undefined };
}
},
readonly: (state) => {
return !obj.isUserNameEnabled(state);
}
}, {
id: 'email', label: gettext('Email'), type: 'text',
deps: ['id'],
readonly: (state) => {
if (obj.isNew(state)) {
return false;
} else {
return !obj.isNotCurrentUser(state) || state.auth_source == AUTH_METHODS['INTERNAL'];
}
}
}, {
id: 'role', label: gettext('Role'), type: 'select',
options: obj.options.roles,
controlProps: {
allowClear: false,
openOnEnter: false,
},
readonly: (state) => {
if (obj.isNew(state)) {
return false;
}
return !obj.isNotCurrentUser(state);
}
}, {
id: 'active', label: gettext('Active'), type: 'switch',
readonly: (state) => {
if (obj.isNew(state)) {
return false;
}
return !obj.isNotCurrentUser(state);
}
}, {
id: 'newPassword', label: gettext('New password'), type: 'password',
deps: ['auth_source'], controlProps: {
autoComplete: 'new-password',
},
visible: (state)=>obj.isNotCurrentUser(state) && state.auth_source == AUTH_METHODS['INTERNAL'],
}, {
id: 'confirmPassword', label: gettext('Confirm password'), type: 'password',
deps: ['auth_source'], controlProps: {
autoComplete: 'new-password',
},
visible: (state)=>obj.isNotCurrentUser(state) && state.auth_source == AUTH_METHODS['INTERNAL'],
}, {
id: 'locked', label: gettext('Locked'), type: 'switch',
readonly: (state) => {
return !state.locked;
}
}
];
}
validate(state, setError) {
let msg;
let obj = this;
let minPassLen = this.pgAdmin.password_length_min;
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 == AUTH_METHODS['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);
}
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 < minPassLen) {
msg = gettext('Password must be at least %s characters for user %s', minPassLen, 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;
}
}
export default function UserDialog({user, options, onClose}) {
const pgAdmin = usePgAdmin();
const schema = useMemo(() => new UserSchema(options, pgAdmin), []);
const isEdit = Boolean(user.id);
const api = getApiInstance();
const onSaveClick = (_isNew, changeData)=>{
return new Promise((resolve, reject)=>{
try {
api.post(url_for('user_management.save'), changeData)
.then(()=>{
pgAdmin.Browser.notifier.success(gettext('Users Saved Successfully'));
resolve();
onClose(null, true);
})
.catch((err)=>{
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
});
} catch (error) {
reject(Error(parseApiError(error)));
}
});
};
return <ErrorBoundary>
<SchemaView
formType={'dialog'}
getInitData={()=>{ return Promise.resolve(user); }}
schema={schema}
viewHelperProps={{
mode: isEdit ? 'edit' : 'create',
}}
onSave={onSaveClick}
onClose={onClose}
hasSQL={false}
disableSqlHelp={true}
disableDialogHelp={true}
isTabView={false}
/>
</ErrorBoundary>;
}
UserDialog.propTypes = {
user: PropTypes.object,
options: PropTypes.object,
onClose: PropTypes.func,
};

View File

@ -1,467 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import SchemaView from '../../../../static/js/SchemaView';
import BaseUISchema from '../../../../static/js/SchemaView/base_schema.ui';
import pgAdmin from 'sources/pgadmin';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import PropTypes from 'prop-types';
import getApiInstance, { parseApiError } from '../../../../static/js/api_instance';
import {AUTH_METHODS} from 'pgadmin.browser.constants';
import current_user from 'pgadmin.user_management.current_user';
import { isEmptyString } from '../../../../static/js/validators';
import { showChangeOwnership } from '../../../../static/js/Dialogs/index';
import _ from 'lodash';
const StyledBox = styled(Box)(() => ({
height: '100%',
'& .UserManagementDialog-root': {
padding: 0 + ' !important',
}
}));
class UserManagementCollection extends BaseUISchema {
constructor() {
super({
id: undefined,
username: undefined,
email: undefined,
active: true,
role: '2',
newPassword: undefined,
confirmPassword: undefined,
locked: false,
auth_source: AUTH_METHODS['INTERNAL']
});
this.authOnlyInternal = (current_user['auth_sources'].length == 1 &&
current_user['auth_sources'].includes(AUTH_METHODS['INTERNAL']));
}
setAuthSources(src) {
this.authSources = src;
}
setRoleOptions(src) {
this.roleOptions = src;
}
get idAttribute() {
return 'id';
}
isUserNameEnabled(state) {
return !(this.authOnlyInternal || state.auth_source == AUTH_METHODS['INTERNAL']);
}
isEditable(state) {
return state.id != current_user['id'];
}
get baseFields() {
let obj = this;
return [
{
id: 'auth_source', label: gettext('Authentication source'),
cell: (state)=> {
return {
cell: 'select',
options: ()=> {
if (obj.isNew(state)) {
return Promise.resolve(obj.authSources.filter((s)=> current_user['auth_sources'].includes(s.value)));
}
return Promise.resolve(obj.authSources);
},
optionsReloadBasis: obj.isNew(state)
};
},
minWidth: 110, width: 110,
controlProps: {
allowClear: false,
openOnEnter: false,
first_empty: false,
},
visible: function() {
return !obj.authOnlyInternal;
},
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)) {
return {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;
return obj.isEditable(state) && state.auth_source != AUTH_METHODS['INTERNAL'];
}
}, {
id: 'role', label: gettext('Role'),
cell: () => ({
cell: 'select',
options: obj.roleOptions,
controlProps: {
allowClear: false,
openOnEnter: false,
first_empty: false,
},
}),
minWidth: 95, width: 95,
editable: (state)=> {
return obj.isEditable(state);
}
}, {
id: 'active', label: gettext('Active'), cell: 'switch', width: 60, enableResizing: false,
editable: (state)=> {
return obj.isEditable(state);
}
}, {
id: 'newPassword', label: gettext('New password'), cell: 'password',
minWidth: 90, width: 90, deps: ['auth_source'], controlProps: {
autoComplete: 'new-password',
},
editable: (state)=> {
return obj.isEditable(state) && state.auth_source == AUTH_METHODS['INTERNAL'];
}
}, {
id: 'confirmPassword', label: gettext('Confirm password'), cell: 'password',
minWidth: 90, width: 90, deps: ['auth_source'], controlProps: {
autoComplete: 'new-password',
},
editable: (state)=> {
return obj.isEditable(state) && state.auth_source == AUTH_METHODS['INTERNAL'];
}
}, {
id: 'locked', label: gettext('Locked'), cell: 'switch', width: 60, enableResizing: false,
editable: (state)=> {
return state.locked;
}
}
];
}
validate(state, setError) {
let msg;
let obj = this;
let minPassLen = pgAdmin.password_length_min;
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 != AUTH_METHODS['INTERNAL']) {
if (obj.isNew(state) && obj.top?.sessData?.userManagement) {
for (let user of obj.top.sessData.userManagement) {
if (user?.id &&
user.username.toLowerCase() == state.username.toLowerCase() &&
user.auth_source == state.auth_source) {
msg = gettext('User name \'%s\' already exists', state.username);
setError('username', msg);
return true;
}
}
}
}
if (state.auth_source == AUTH_METHODS['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);
}
if (obj.isNew(state) && obj.top?.sessData?.userManagement) {
for (let user of obj.top.sessData.userManagement) {
if (user?.id &&
user.email?.toLowerCase() == state.email?.toLowerCase()) {
msg = gettext('Email address \'%s\' already exists', state.email);
setError('email', msg);
return true;
}
}
}
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 < minPassLen) {
msg = gettext('Password must be at least %s characters for user %s', minPassLen, 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() {
super({refreshBrowserTree: false});
this.userManagementCollObj = new UserManagementCollection();
this.changeOwnership = false;
}
setAuthSources(src) {
this.userManagementCollObj.setAuthSources(src);
}
setRoleOptions(src) {
this.userManagementCollObj.setRoleOptions(src);
}
deleteUser(deleteRow) {
pgAdmin.Browser.notifier.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,
addOnTop: true,
canDeleteRow: (row)=>{
return row['id'] != current_user['id'];
},
onDelete: (row, deleteRow)=> {
if (_.isUndefined(row['id'])) {
deleteRow();
return;
}
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,
()=> {
this.changeOwnership = true;
deleteRow();
}
);
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(parseApiError(err));
});
} else {
obj.deleteUser(deleteRow);
}
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(parseApiError(err));
obj.deleteUser(deleteRow);
});
},
canSearch: true
},
{
id: 'refreshBrowserTree', visible: false, type: 'switch',
mode: ['non_supported'],
deps: ['userManagement'], depChange: ()=> {
return { refreshBrowserTree: this.changeOwnership };
}
}
];
}
}
function UserManagementDialog({onClose}) {
const [authSources, setAuthSources] = React.useState([]);
const [roles, setRoles] = React.useState([]);
const api = getApiInstance();
const schema = React.useRef(null);
const fetchData = async () => {
try {
api.get(url_for('user_management.auth_sources'))
.then(res=>{
setAuthSources(res.data);
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(err);
});
api.get(url_for('user_management.roles'))
.then(res=>{
setRoles(res.data);
})
.catch((err)=>{
pgAdmin.Browser.notifier.error(parseApiError(err));
});
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
}
};
React.useEffect(() => {
fetchData();
}, []);
const onSaveClick = (_isNew, changeData)=>{
return new Promise((resolve, reject)=>{
try {
if (changeData['refreshBrowserTree']) {
// Confirmation dialog to refresh the browser tree.
pgAdmin.Browser.notifier.confirm(
gettext('Object explorer tree refresh required'),
gettext('The ownership of the shared server was changed or the shared server was deleted, so the object explorer tree refresh is required. Do you wish to refresh the tree?'),
function () {
pgAdmin.Browser.tree.destroy();
},
function () {
return true;
},
gettext('Refresh'),
gettext('Later')
);
}
api.post(url_for('user_management.save'), changeData['userManagement'])
.then(()=>{
pgAdmin.Browser.notifier.success('Users Saved Successfully');
resolve();
onClose();
})
.catch((err)=>{
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
});
} catch (error) {
reject(parseApiError(error));
}
});
};
const authSourcesOptions = authSources.map((m)=>({
label: m.label,
value: m.value,
}));
const roleOptions = roles.map((m) => ({
label: m.name,
value: m.id,
}));
if (!schema.current)
schema.current = new UserManagementSchema();
if(authSourcesOptions.length <= 0) {
return <></>;
}
if(roleOptions.length <= 0) {
return <></>;
}
schema.current.setAuthSources(authSourcesOptions);
schema.current.setRoleOptions(roleOptions);
const onDialogHelp = () => {
window.open(
url_for('help.static', { 'filename': 'user_management.html' }),
'pgadmin_help'
);
};
return <StyledBox><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 instanceof Error ? err : Error(gettext('Something went wrong')));
});
}); }}
schema={schema.current}
viewHelperProps={{
mode: 'edit',
}}
onSave={onSaveClick}
onClose={onClose}
onHelp={onDialogHelp}
hasSQL={false}
disableSqlHelp={true}
isTabView={false}
formClassName='UserManagementDialog-root'
/></StyledBox>;
}
UserManagementDialog.propTypes = {
onClose: PropTypes.func
};
export function showUserManagement() {
const title = gettext('User Management');
pgAdmin.Browser.notifier.showModal(title, (onClose) => {
return <UserManagementDialog
onClose={()=>{onClose();}}
/>;
},
{ isFullScreen: false, isResizeable: true, showFullScreen: false, isFullWidth: true,
dialogWidth: pgAdmin.Browser.stdW.lg, dialogHeight: pgAdmin.Browser.stdH.md, id: 'id-user-management'});
}

View File

@ -0,0 +1,303 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useMemo, useRef } from 'react';
import { getDeleteCell, getEditCell, getSwitchCell } from '../../../../static/js/components/PgReactTableStyled';
import gettext from 'sources/gettext';
import pgAdmin from 'sources/pgadmin';
import getApiInstance, { parseApiError } from '../../../../static/js/api_instance';
import PgTable from 'sources/components/PgTable';
import url_for from 'sources/url_for';
import { BROWSER_PANELS } from '../../../../browser/static/js/constants';
import ErrorBoundary from '../../../../static/js/helpers/ErrorBoundary';
import UserDialog from './UserDialog';
import { Box } from '@mui/material';
import Loader from 'sources/components/Loader';
import {Add as AddIcon, SyncRounded, Help as HelpIcon} from '@mui/icons-material';
import PropTypes from 'prop-types';
import { PgButtonGroup, PgIconButton } from '../../../../static/js/components/Buttons';
import { showChangeOwnership } from '../../../../static/js/Dialogs';
import { isEmptyString } from '../../../../static/js/validators';
function CustomHeader({updateUsers, options}) {
return (
<Box>
<PgButtonGroup>
<PgIconButton
icon={<AddIcon style={{ height: '1.4rem' }} />}
aria-label="Create User"
title={gettext('Create User...')}
onClick={() => {
const panelTitle = gettext('Create User');
const panelId = BROWSER_PANELS.USER_MANAGEMENT + '-new';
pgAdmin.Browser.docker.default_workspace.openDialog({
id: panelId,
title: panelTitle,
content: (
<ErrorBoundary>
<UserDialog
options={options}
user={{}}
onClose={(_e, reload) => {
pgAdmin.Browser.docker.default_workspace.close(panelId, true);
reload && updateUsers();
}}
/>
</ErrorBoundary>
)
}, pgAdmin.Browser.stdW.md, pgAdmin.Browser.stdH.lg);
}}
></PgIconButton>
<PgIconButton
icon={<SyncRounded style={{ height: '1.4rem' }} />}
aria-label="Refresh"
title={gettext('Refresh')}
onClick={() => {
updateUsers();
}}
></PgIconButton>
<PgIconButton
icon={<HelpIcon style={{height: '1.4rem'}}/>}
aria-label="Help"
title={gettext('Help')}
onClick={() => {
window.open(url_for('help.static', {'filename': 'user_management.html'}));
}}
></PgIconButton>
</PgButtonGroup>
</Box>
);
}
CustomHeader.propTypes = {
updateUsers: PropTypes.func,
options: PropTypes.object,
};
export default function Users() {
const authSources = useRef([]);
const roles = useRef([]);
const [loading, setLoading] = React.useState('');
const [tableData, setTableData] = React.useState([]);
const [selectedRows, setSelectedRows] = React.useState({});
const api = getApiInstance();
const onDeleteClick = (row) => {
const deleteRow = async () => {
setLoading(gettext('Deleting user...'));
try {
await api.delete(url_for('user_management.save_id', { id: row.original.id }));
pgAdmin.Browser.notifier.success(gettext('User deleted successfully.'));
updateList();
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
}
setLoading('');
};
pgAdmin.Browser.notifier.confirm(gettext('Delete User'), gettext('Are you sure you want to delete the user %s?', row.original.username),
async () => {
setLoading(gettext('Deleting user...'));
try {
const resp = await api.get(url_for('user_management.shared_servers', {'uid': row['id']}));
const noOfSharedServers = resp.data?.data?.shared_servers ?? 0;
if (noOfSharedServers > 0) {
const resp = await api.get(url_for('user_management.admin_users', {'uid': row['id']}));
showChangeOwnership(
gettext('Change ownership'),
resp.data?.data?.result?.data,
noOfSharedServers,
{'id': row.original['id'], 'name': !isEmptyString(row.original['email']) ? row.original['email'] : row.original['username']},
()=> {
pgAdmin.Browser.notifier.confirm(
gettext('Object explorer tree refresh required'),
gettext('The ownership of the shared server was changed or the shared server was deleted, so the object explorer tree refresh is required. Do you wish to refresh the tree?'),
function () {
pgAdmin.Browser.tree.destroy();
},
function () {
return true;
},
gettext('Refresh'),
gettext('Later')
);
deleteRow();
}
);
} else {
deleteRow();
}
}
catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
}
setLoading('');
});
};
const onEditClick = (row) => {
const user = row.original;
const panelTitle = gettext('Edit User - %s', user.username);
const panelId = BROWSER_PANELS.USER_MANAGEMENT + '-edit-' + user.id;
pgAdmin.Browser.docker.default_workspace.openDialog({
id: panelId,
title: panelTitle,
content: (
<ErrorBoundary>
<UserDialog
options={{
authSources: authSources.current.map((s) => ({ label: s.label, value: s.value })),
roles: roles.current.map((r) => ({ label: r.name, value: r.id })),
}}
user={user}
onClose={(_e, reload) => {
pgAdmin.Browser.docker.default_workspace.close(panelId, true);
reload && updateList();
}}
/>
</ErrorBoundary>
)
}, pgAdmin.Browser.stdW.md, pgAdmin.Browser.stdH.lg);
};
const columns = useMemo(() => {
return [{
header: () => null,
enableSorting: false,
enableResizing: false,
enableFilters: false,
size: 35,
maxSize: 35,
minSize: 35,
id: 'btn-delete',
cell: getDeleteCell({ title: gettext('Delete User'), onClick: onDeleteClick, isDisabled: (row) => !row.original.canDrop }),
},{
header: () => null,
enableSorting: false,
enableResizing: false,
enableFilters: false,
size: 35,
maxSize: 35,
minSize: 35,
id: 'btn-edit',
cell: getEditCell({ title: gettext('Edit User'), onClick: onEditClick }),
},
{
header: gettext('Auth Source'),
accessorFn: (row) => authSources.current.find((s)=>s.value == row.auth_source).label,
enableSorting: true,
enableResizing: true,
size: 120,
minSize: 100,
enableFilters: true,
},
{
header: gettext('Username'),
accessorKey: 'username',
enableSorting: true,
enableResizing: true,
size: 200,
minSize: 150,
enableFilters: true,
},
{
header: gettext('Email'),
accessorKey: 'email',
enableSorting: true,
enableResizing: true,
size: 200,
minSize: 150,
enableFilters: true,
},
{
header: gettext('Role'),
accessorFn: (row) => roles.current.find((r)=>r.id == row.role).name,
enableSorting: true,
enableResizing: true,
size: 100,
minSize: 80,
enableFilters: true,
},
{
header: gettext('Active'),
accessorKey: 'active',
enableSorting: true,
enableResizing: true,
size: 50,
minSize: 50,
enableFilters: true,
cell: getSwitchCell(),
},
{
header: gettext('Locked'),
accessorKey: 'locked',
enableSorting: true,
enableResizing: true,
size: 50,
minSize: 50,
enableFilters: true,
cell: getSwitchCell(),
}];
}, []);
const updateList = async () => {
setLoading(gettext('Fetching users...'));
try {
const res = await api.get(url_for('user_management.users'));
setTableData(res.data);
} catch (error) {
pgAdmin.Browser.notifier.error(parseApiError(error));
}
setLoading('');
};
const initialize = async () => {
setLoading(gettext('Loading...'));
try {
const res = await Promise.all([
api.get(url_for('user_management.auth_sources')),
api.get(url_for('user_management.roles')),
]);
authSources.current = res[0].data;
roles.current = res[1].data;
updateList();
} catch (error) {
setLoading('');
pgAdmin.Browser.notifier.error(parseApiError(error));
}
};
useEffect(() => {
initialize();
}, []);
return (
<Box sx={{position: 'relative', height: '100%'}}>
<Loader message={loading} />
<PgTable
data-test="users"
columns={columns}
data={tableData}
sortOptions={[{ id: 'username', desc: true }]}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
caveTable={false}
tableNoBorder={false}
tableProps={{
getRowId: (row) => {
return row.id;
}
}}
customHeader={<CustomHeader updateUsers={updateList} options={{
authSources: authSources.current.map((s) => ({ label: s.label, value: s.value })),
roles: roles.current.map((r) => ({ label: r.name, value: r.id })),
}} />}
></PgTable>
</Box>
);
}

View File

@ -7,10 +7,12 @@
//
//////////////////////////////////////////////////////////////
import React from 'react';
import pgAdmin from 'sources/pgadmin';
import gettext from 'sources/gettext';
import { showChangeUserPassword, showUrlDialog } from '../../../../static/js/Dialogs/index';
import { showUserManagement } from './UserManagementDialog';
import { BROWSER_PANELS } from '../../../../browser/static/js/constants';
import Component from './Component';
class UserManagement {
static instance;
@ -38,9 +40,18 @@ class UserManagement {
showUrlDialog(gettext('Authentication'), url, 'mfa.html', 1000, 600);
}
// This is a callback function to show user management dialog.
show_users() {
showUserManagement();
// This is a callback function to show user management tab.
launchUserManagement() {
pgAdmin.Browser.docker.default_workspace.openTab({
id: BROWSER_PANELS.USER_MANAGEMENT,
title: gettext('User Management'),
content: <Component />,
closable: true,
cache: false,
group: 'playground'
}, BROWSER_PANELS.MAIN, 'middle', true);
return true;
}
}

View File

@ -9,5 +9,6 @@
module.exports = {
'id': 'pgadmin4@pgadmin.org',
'current_auth_source': 'internal'
'current_auth_source': 'internal',
'auth_sources': ['internal'],
};

View File

@ -38,5 +38,8 @@ module.exports = {
'bgprocess.detailed_status': '/misc/bgprocess/<pid>/<int:out>/<int:err>/',
'bgprocess.list': '/misc/bgprocess/',
'bgprocess.stop_process': '/misc/bgprocess/stop/<pid>',
'bgprocess.acknowledge': '/misc/bgprocess/<pid>'
'bgprocess.acknowledge': '/misc/bgprocess/<pid>',
'user_management.auth_sources': '/user_management/auth_sources',
'user_management.roles': '/user_management/roles',
'user_management.users': '/user_management/users',
};

View File

@ -0,0 +1,38 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { act, render } from '@testing-library/react';
import { withBrowser } from '../genericFunctions';
import UserDialog from '../../../pgadmin/tools/user_management/static/js/UserDialog';
describe('UserDialog', ()=>{
describe('Component', ()=>{
const UserDialogWithBrowser = withBrowser(UserDialog);
it('init', async ()=>{
let ctrl;
await act(async ()=>{
ctrl = await render(<UserDialogWithBrowser
options={{
authSources: [{id: 1, label: 'internal', value: 'internal'}],
roles: [{value: 1, label: 'Administrator'}, {value: 2, label: 'User'}],
}}
user={{}}
onClose={() => {
// Intentionally left blank
}}
/>);
});
expect(ctrl.container.querySelector('.FormView-nonTabPanel .MuiFormLabel-root').textContent).toBe('Authentication source');
});
});
});

View File

@ -0,0 +1,53 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { act, render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import { withBrowser } from '../genericFunctions';
import Users from '../../../pgadmin/tools/user_management/static/js/Users';
describe('Users', ()=>{
let networkMock;
beforeEach(()=>{
networkMock = new MockAdapter(axios);
networkMock.onGet('/user_management/auth_sources').reply(200,
[{'id':1,'label':'internal','value':'internal'}]
);
networkMock.onGet('/user_management/roles').reply(200,
[
{'id':1,'name':'Administrator'},
{'id':2,'name':'User'}
],
);
networkMock.onGet('/user_management/users').reply(200,
[{'id':1,'label':'postgres','value':'postgres', 'auth_source': 'internal', 'role': 1}],
);
});
afterEach(() => {
networkMock.restore();
});
describe('Component', ()=>{
const UsersWithBrowser = withBrowser(Users);
it('init', async ()=>{
let ctrl;
await act(async ()=>{
ctrl = await render(<UsersWithBrowser />);
});
expect(ctrl.container.querySelectorAll('[data-test="users"]').length).toBe(1);
});
});
});

View File

@ -155,7 +155,7 @@ let webpackShimConfig = {
'pgadmin.tools.erd': path.join(__dirname, './pgadmin/tools/erd/static/js/'),
'pgadmin.tools.psql': path.join(__dirname, './pgadmin/tools/psql/static/js/'),
'pgadmin.tools.sqleditor': path.join(__dirname, './pgadmin/tools/sqleditor/static/js/'),
'pgadmin.tools.user_management': path.join(__dirname, './pgadmin/tools/user_management/static/js/user_management'),
'pgadmin.tools.user_management': path.join(__dirname, './pgadmin/tools/user_management/static/js/'),
'pgadmin.user_management.current_user': '/user_management/current_user',
},
externals: [