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: [