diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js
index 24baa5264..fbb07cfe8 100644
--- a/web/pgadmin/browser/templates/browser/js/utils.js
+++ b/web/pgadmin/browser/templates/browser/js/utils.js
@@ -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()
}
},
{
diff --git a/web/pgadmin/static/js/Theme/index.jsx b/web/pgadmin/static/js/Theme/index.jsx
index 40ce813ef..10efb39e4 100644
--- a/web/pgadmin/static/js/Theme/index.jsx
+++ b/web/pgadmin/static/js/Theme/index.jsx
@@ -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: {
diff --git a/web/pgadmin/static/js/components/PgReactTableStyled.jsx b/web/pgadmin/static/js/components/PgReactTableStyled.jsx
index 9c8fa8221..37ab1efc6 100644
--- a/web/pgadmin/static/js/components/PgReactTableStyled.jsx
+++ b/web/pgadmin/static/js/components/PgReactTableStyled.jsx
@@ -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 } className='pgrt-cell-button'
onClick={()=>{
- row.toggleExpanded();
+ onClick ? onClick(row) : row.toggleExpanded();
}} disabled={isDisabled?.(row)}
/>;
};
diff --git a/web/pgadmin/static/js/components/PgTable.jsx b/web/pgadmin/static/js/components/PgTable.jsx
index 6862c241b..ee54556bc 100644
--- a/web/pgadmin/static/js/components/PgTable.jsx
+++ b/web/pgadmin/static/js/components/PgTable.jsx
@@ -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={}
/>
}
diff --git a/web/pgadmin/tools/user_management/__init__.py b/web/pgadmin/tools/user_management/__init__.py
index 628e8ed8d..caa465600 100644
--- a/web/pgadmin/tools/user_management/__init__.py
+++ b/web/pgadmin/tools/user_management/__init__.py
@@ -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/', 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():
diff --git a/web/pgadmin/tools/user_management/static/js/Component.jsx b/web/pgadmin/tools/user_management/static/js/Component.jsx
new file mode 100644
index 000000000..8b4046802
--- /dev/null
+++ b/web/pgadmin/tools/user_management/static/js/Component.jsx
@@ -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 (
+
+
+
+ {
+ setTabValue(selTabValue);
+ }}
+ variant="scrollable"
+ scrollButtons="auto"
+ action={(ref)=>ref?.updateIndicator()}
+ >
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/pgadmin/tools/user_management/static/js/UserDialog.jsx b/web/pgadmin/tools/user_management/static/js/UserDialog.jsx
new file mode 100644
index 000000000..131b2b333
--- /dev/null
+++ b/web/pgadmin/tools/user_management/static/js/UserDialog.jsx
@@ -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
+ { return Promise.resolve(user); }}
+ schema={schema}
+ viewHelperProps={{
+ mode: isEdit ? 'edit' : 'create',
+ }}
+ onSave={onSaveClick}
+ onClose={onClose}
+ hasSQL={false}
+ disableSqlHelp={true}
+ disableDialogHelp={true}
+ isTabView={false}
+ />
+ ;
+}
+
+UserDialog.propTypes = {
+ user: PropTypes.object,
+ options: PropTypes.object,
+ onClose: PropTypes.func,
+};
\ No newline at end of file
diff --git a/web/pgadmin/tools/user_management/static/js/UserManagementDialog.jsx b/web/pgadmin/tools/user_management/static/js/UserManagementDialog.jsx
deleted file mode 100644
index 64c2224bc..000000000
--- a/web/pgadmin/tools/user_management/static/js/UserManagementDialog.jsx
+++ /dev/null
@@ -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 { 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'
- />;
-}
-
-UserManagementDialog.propTypes = {
- onClose: PropTypes.func
-};
-
-export function showUserManagement() {
- const title = gettext('User Management');
- pgAdmin.Browser.notifier.showModal(title, (onClose) => {
- return {onClose();}}
- />;
- },
- { isFullScreen: false, isResizeable: true, showFullScreen: false, isFullWidth: true,
- dialogWidth: pgAdmin.Browser.stdW.lg, dialogHeight: pgAdmin.Browser.stdH.md, id: 'id-user-management'});
-}
diff --git a/web/pgadmin/tools/user_management/static/js/Users.jsx b/web/pgadmin/tools/user_management/static/js/Users.jsx
new file mode 100644
index 000000000..41325d581
--- /dev/null
+++ b/web/pgadmin/tools/user_management/static/js/Users.jsx
@@ -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 (
+
+
+ }
+ 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: (
+
+ {
+ pgAdmin.Browser.docker.default_workspace.close(panelId, true);
+ reload && updateUsers();
+ }}
+ />
+
+ )
+ }, pgAdmin.Browser.stdW.md, pgAdmin.Browser.stdH.lg);
+ }}
+ >
+ }
+ aria-label="Refresh"
+ title={gettext('Refresh')}
+ onClick={() => {
+ updateUsers();
+ }}
+ >
+ }
+ aria-label="Help"
+ title={gettext('Help')}
+ onClick={() => {
+ window.open(url_for('help.static', {'filename': 'user_management.html'}));
+ }}
+ >
+
+
+ );
+}
+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: (
+
+ ({ 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();
+ }}
+ />
+
+ )
+ }, 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 (
+
+
+ {
+ return row.id;
+ }
+ }}
+ customHeader={ ({ label: s.label, value: s.value })),
+ roles: roles.current.map((r) => ({ label: r.name, value: r.id })),
+ }} />}
+ >
+
+ );
+}
\ No newline at end of file
diff --git a/web/pgadmin/tools/user_management/static/js/user_management.js b/web/pgadmin/tools/user_management/static/js/index.js
similarity index 68%
rename from web/pgadmin/tools/user_management/static/js/user_management.js
rename to web/pgadmin/tools/user_management/static/js/index.js
index 915ab44cb..de5eb4e01 100644
--- a/web/pgadmin/tools/user_management/static/js/user_management.js
+++ b/web/pgadmin/tools/user_management/static/js/index.js
@@ -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: ,
+ closable: true,
+ cache: false,
+ group: 'playground'
+ }, BROWSER_PANELS.MAIN, 'middle', true);
+
+ return true;
}
}
diff --git a/web/regression/javascript/fake_current_user.js b/web/regression/javascript/fake_current_user.js
index 068d07232..91b25d464 100644
--- a/web/regression/javascript/fake_current_user.js
+++ b/web/regression/javascript/fake_current_user.js
@@ -9,5 +9,6 @@
module.exports = {
'id': 'pgadmin4@pgadmin.org',
- 'current_auth_source': 'internal'
+ 'current_auth_source': 'internal',
+ 'auth_sources': ['internal'],
};
diff --git a/web/regression/javascript/fake_endpoints.js b/web/regression/javascript/fake_endpoints.js
index c5122d02e..e9ab2ec27 100644
--- a/web/regression/javascript/fake_endpoints.js
+++ b/web/regression/javascript/fake_endpoints.js
@@ -38,5 +38,8 @@ module.exports = {
'bgprocess.detailed_status': '/misc/bgprocess////',
'bgprocess.list': '/misc/bgprocess/',
'bgprocess.stop_process': '/misc/bgprocess/stop/',
- 'bgprocess.acknowledge': '/misc/bgprocess/'
+ 'bgprocess.acknowledge': '/misc/bgprocess/',
+ 'user_management.auth_sources': '/user_management/auth_sources',
+ 'user_management.roles': '/user_management/roles',
+ 'user_management.users': '/user_management/users',
};
diff --git a/web/regression/javascript/user_management/UserDialog.spec.js b/web/regression/javascript/user_management/UserDialog.spec.js
new file mode 100644
index 000000000..95de83cf5
--- /dev/null
+++ b/web/regression/javascript/user_management/UserDialog.spec.js
@@ -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( {
+ // Intentionally left blank
+ }}
+ />);
+ });
+ expect(ctrl.container.querySelector('.FormView-nonTabPanel .MuiFormLabel-root').textContent).toBe('Authentication source');
+ });
+ });
+});
diff --git a/web/regression/javascript/user_management/Users.spec.js b/web/regression/javascript/user_management/Users.spec.js
new file mode 100644
index 000000000..11f7b1b60
--- /dev/null
+++ b/web/regression/javascript/user_management/Users.spec.js
@@ -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();
+ });
+ expect(ctrl.container.querySelectorAll('[data-test="users"]').length).toBe(1);
+ });
+ });
+});
diff --git a/web/webpack.shim.js b/web/webpack.shim.js
index 03042600c..41670d7f1 100644
--- a/web/webpack.shim.js
+++ b/web/webpack.shim.js
@@ -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: [