diff --git a/CyberCP/settings.py b/CyberCP/settings.py index 9abe37227..7b73a8063 100644 --- a/CyberCP/settings.py +++ b/CyberCP/settings.py @@ -53,6 +53,20 @@ _default_origins = [ # Merge environment and default origins, avoiding duplicates CSRF_TRUSTED_ORIGINS = list(dict.fromkeys(_csrf_origins_list + _default_origins)) +# Optional file: one trusted origin per line (e.g. https://203.0.113.1:2087) for IP:port panel access. +# Create /etc/cyberpanel/csrf_trusted_origins on the server if JSON POSTs get 403 CSRF when using HTTPS by IP. +_csrf_trusted_origins_file = '/etc/cyberpanel/csrf_trusted_origins' +if os.path.isfile(_csrf_trusted_origins_file): + try: + with open(_csrf_trusted_origins_file, 'r', encoding='utf-8', errors='replace') as _csrf_f: + for _csrf_line in _csrf_f: + _csrf_line = _csrf_line.strip() + if _csrf_line and not _csrf_line.startswith('#'): + if _csrf_line not in CSRF_TRUSTED_ORIGINS: + CSRF_TRUSTED_ORIGINS.append(_csrf_line) + except OSError: + pass + # Application definition INSTALLED_APPS = [ diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index a3a2f71b4..610b163c4 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -2130,7 +2130,11 @@ {% endif %} - + {% endif %} + {% if admin or managePlugins %} + {% if managePlugins and not admin %} +
{% trans "PLUGINS" %}
+ {% endif %}
diff --git a/plogical/acl.py b/plogical/acl.py index 9af6b0a87..815cd394b 100644 --- a/plogical/acl.py +++ b/plogical/acl.py @@ -283,6 +283,17 @@ class ACLManager: finalResponse['mailServerSSL'] = config['mailServerSSL'] finalResponse['sslReconcile'] = config.get('sslReconcile', 0) + ## Plugin management (Plugin Store / installed plugins UI and APIs) + + _mpv = config.get('managePlugins', 0) + if _mpv in (1, True, '1', 'true'): + finalResponse['managePlugins'] = 1 + else: + try: + finalResponse['managePlugins'] = 1 if int(_mpv) else 0 + except (TypeError, ValueError): + finalResponse['managePlugins'] = 0 + return finalResponse @staticmethod diff --git a/plogical/plugin_acl.py b/plogical/plugin_acl.py new file mode 100644 index 000000000..e7453ff52 --- /dev/null +++ b/plogical/plugin_acl.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""Shared ACL checks for CyberPanel plugin management (core + store plugins).""" +from functools import wraps +from django.http import JsonResponse + + +def user_can_manage_plugins(request): + """True if session user is full admin or has managePlugins ACL.""" + try: + user_id = request.session['userID'] + except KeyError: + return False + try: + from plogical.acl import ACLManager + acl = ACLManager.loadedACL(user_id) + if acl.get('admin') == 1: + return True + try: + return int(acl.get('managePlugins', 0) or 0) == 1 + except (TypeError, ValueError): + return False + except BaseException: + return False + + +def deny_plugin_manage_json_response(request): + """401 if no session, else 403 JSON for plugin management APIs.""" + try: + request.session['userID'] + except KeyError: + return JsonResponse({ + 'success': False, + 'error_message': 'Authentication required.', + 'error': 'Authentication required.', + }, status=401) + return JsonResponse({ + 'success': False, + 'error_message': 'You are not authorized to manage plugins.', + 'error': 'You are not authorized to manage plugins.', + }, status=403) + + +def require_manage_plugins_api(view_func): + """Decorator: JSON 401/403 if user cannot manage plugins (use after login/session check).""" + @wraps(view_func) + def _wrapped(request, *args, **kwargs): + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) + return view_func(request, *args, **kwargs) + return _wrapped diff --git a/pluginHolder/views.py b/pluginHolder/views.py index 24938697a..41088c9dd 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -12,11 +12,13 @@ import json from datetime import datetime, timedelta from xml.etree import ElementTree from plogical.httpProc import httpProc +from plogical.plugin_acl import user_can_manage_plugins, deny_plugin_manage_json_response from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging import sys import urllib.request import urllib.error import time +import inspect sys.path.append('/usr/local/CyberCP') from pluginInstaller.pluginInstaller import pluginInstaller from .patreon_verifier import PatreonVerifier @@ -43,6 +45,36 @@ PLUGIN_SOURCE_PATHS = ['/home/cyberpanel/plugins', '/home/cyberpanel-plugins'] # These plugins show "Built-in" badge and only Settings button (no Deactivate/Uninstall) BUILTIN_PLUGINS = frozenset(['emailMarketing', 'emailPremium']) + +def _install_plugin_compat(plugin_name, zip_path_abs): + """ + Call pluginInstaller.installPlugin with zip_path when supported (newer CyberPanel). + Older installs only accept installPlugin(pluginName) and expect pluginName.zip in CWD. + """ + zip_path_abs = os.path.abspath(zip_path_abs) + work_dir = os.path.dirname(zip_path_abs) + use_zip_kw = False + try: + sig = inspect.signature(pluginInstaller.installPlugin) + use_zip_kw = 'zip_path' in sig.parameters + except (TypeError, ValueError): + use_zip_kw = False + if use_zip_kw: + pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) + return + prev_cwd = os.getcwd() + try: + os.chdir(work_dir) + expected_zip = os.path.join(work_dir, plugin_name + '.zip') + if zip_path_abs != expected_zip: + shutil.copy2(zip_path_abs, expected_zip) + pluginInstaller.installPlugin(plugin_name) + finally: + try: + os.chdir(prev_cwd) + except Exception: + pass + # Core CyberPanel app dirs under /usr/local/CyberCP that must not be counted as "installed plugins" # (matches pluginHolder.urls so Installed count = store/plugin dirs only, not core apps) RESERVED_PLUGIN_DIRS = frozenset([ @@ -175,7 +207,7 @@ def _set_plugin_state(plugin_name, enabled): def help_page(request): """Display plugin development help page""" mailUtilities.checkHome() - proc = httpProc(request, 'pluginHolder/help.html', {}, 'admin') + proc = httpProc(request, 'pluginHolder/help.html', {}, 'managePlugins') return proc.render() def installed(request): @@ -580,7 +612,7 @@ def installed(request): proc = httpProc(request, 'pluginHolder/plugins.html', {'plugins': pluginList, 'error_plugins': errorPlugins, 'installed_count': installed_count, 'active_count': active_count, - 'cache_expiry_timestamp': cache_expiry_timestamp}, 'admin') + 'cache_expiry_timestamp': cache_expiry_timestamp}, 'managePlugins') return proc.render() @csrf_exempt @@ -588,6 +620,8 @@ def installed(request): def install_plugin(request, plugin_name): """Install a plugin""" try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if plugin source exists (in any configured source path) pluginSource = _get_plugin_source_path(plugin_name) if not pluginSource: @@ -638,13 +672,11 @@ def install_plugin(request, plugin_name): zip_path_abs = os.path.abspath(zip_path) if not os.path.exists(zip_path_abs): raise Exception(f'Zip file not found: {zip_path_abs}') - original_cwd = os.getcwd() - os.chdir(temp_dir) try: - # Install using pluginInstaller with explicit zip path (avoids cwd races) + # Install using pluginInstaller (zip_path kw when supported; else legacy CWD + pluginName.zip) try: - pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) + _install_plugin_compat(plugin_name, zip_path_abs) except Exception as install_error: # Log the full error for debugging error_msg = str(install_error) @@ -680,7 +712,6 @@ def install_plugin(request, plugin_name): 'message': f'Plugin {plugin_name} installed successfully' }) finally: - os.chdir(original_cwd) # Cleanup shutil.rmtree(temp_dir, ignore_errors=True) @@ -696,6 +727,8 @@ def install_plugin(request, plugin_name): def uninstall_plugin(request, plugin_name): """Uninstall a plugin - but keep source files and settings""" try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if plugin is installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if not os.path.exists(pluginInstalled): @@ -750,6 +783,8 @@ def uninstall_plugin(request, plugin_name): def enable_plugin(request, plugin_name): """Enable a plugin""" try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if plugin is installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if not os.path.exists(pluginInstalled): @@ -782,6 +817,8 @@ def enable_plugin(request, plugin_name): def disable_plugin(request, plugin_name): """Disable a plugin""" try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if plugin is installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if not os.path.exists(pluginInstalled): @@ -1366,6 +1403,8 @@ def _fetch_plugins_from_github(): def fetch_plugin_store(request): """Fetch plugins from the plugin store with caching""" try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) mailUtilities.checkHome() except Exception as e: logging.writeToFile(f"fetch_plugin_store: checkHome failed: {str(e)}") @@ -1433,6 +1472,8 @@ def upgrade_plugin(request, plugin_name): mailUtilities.checkHome() try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if plugin is installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if not os.path.exists(pluginInstalled): @@ -1520,55 +1561,50 @@ def upgrade_plugin(request, plugin_name): zip_path_abs = os.path.abspath(zip_path) if not os.path.exists(zip_path_abs): raise Exception(f'Zip file not found: {zip_path_abs}') - original_cwd = os.getcwd() - os.chdir(temp_dir) + logging.writeToFile(f"Upgrading plugin using pluginInstaller (zip={zip_path_abs})") + + # Install using pluginInstaller (zip_path kw when supported; else legacy) try: - logging.writeToFile(f"Upgrading plugin using pluginInstaller (zip={zip_path_abs})") - - # Install using pluginInstaller with explicit zip path (this will overwrite existing files) - try: - pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) - except Exception as install_error: - error_msg = str(install_error) - logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}") - # Check if plugin directory exists despite the error - if not os.path.exists(pluginInstalled): - raise Exception(f'Plugin upgrade failed: {error_msg}') - - # Wait for file system to sync - import time - time.sleep(3) - - # Verify plugin was upgraded + _install_plugin_compat(plugin_name, zip_path_abs) + except Exception as install_error: + error_msg = str(install_error) + logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}") + # Check if plugin directory exists despite the error if not os.path.exists(pluginInstalled): - raise Exception(f'Plugin upgrade failed: {pluginInstalled} does not exist after upgrade') - - # Sync meta.xml from GitHub raw so version matches store (archive ZIP can be cached/stale) + raise Exception(f'Plugin upgrade failed: {error_msg}') + + # Wait for file system to sync + import time + time.sleep(3) + + # Verify plugin was upgraded + if not os.path.exists(pluginInstalled): + raise Exception(f'Plugin upgrade failed: {pluginInstalled} does not exist after upgrade') + + # Sync meta.xml from GitHub raw so version matches store (archive ZIP can be cached/stale) + _sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP') + new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP') + # If version unchanged, meta sync may have failed (e.g. network); retry once + if new_version == installed_version: + logging.writeToFile(f"Plugin {plugin_name}: version unchanged after first meta sync, retrying sync") _sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP') new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP') - # If version unchanged, meta sync may have failed (e.g. network); retry once - if new_version == installed_version: - logging.writeToFile(f"Plugin {plugin_name}: version unchanged after first meta sync, retrying sync") - _sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP') - new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP') - if new_version == installed_version: - logging.writeToFile(f"Plugin {plugin_name}: version still {installed_version} after upgrade; meta.xml may not have been updated from GitHub") - - logging.writeToFile(f"Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}") - - backup_message = '' - if backup_path: - backup_message = f' Backup created at: {backup_info.get("timestamp", "unknown")}' - - return JsonResponse({ - 'success': True, - 'message': f'Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}.{backup_message}', - 'backup_created': backup_path is not None, - 'backup_path': backup_path if backup_path else None - }) - finally: - os.chdir(original_cwd) + if new_version == installed_version: + logging.writeToFile(f"Plugin {plugin_name}: version still {installed_version} after upgrade; meta.xml may not have been updated from GitHub") + + logging.writeToFile(f"Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}") + + backup_message = '' + if backup_path: + backup_message = f' Backup created at: {backup_info.get("timestamp", "unknown")}' + + return JsonResponse({ + 'success': True, + 'message': f'Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}.{backup_message}', + 'backup_created': backup_path is not None, + 'backup_path': backup_path if backup_path else None + }) finally: # Cleanup @@ -1600,6 +1636,8 @@ def get_plugin_backups(request, plugin_name): mailUtilities.checkHome() try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) backups = _get_plugin_backups(plugin_name) return JsonResponse({ 'success': True, @@ -1620,6 +1658,8 @@ def revert_plugin(request, plugin_name): mailUtilities.checkHome() try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Get backup path from request data = json.loads(request.body) backup_path = data.get('backup_path') @@ -1684,6 +1724,8 @@ def install_from_store(request, plugin_name): mailUtilities.checkHome() try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if already installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if os.path.exists(pluginInstalled): @@ -1792,55 +1834,50 @@ def install_from_store(request, plugin_name): # Pass absolute path so extraction does not depend on cwd (installPlugin may change cwd) zip_path_abs = os.path.abspath(zip_path) - original_cwd = os.getcwd() - os.chdir(temp_dir) + logging.writeToFile(f"Installing plugin using pluginInstaller (zip={zip_path_abs})") + + # Install using pluginInstaller (zip_path kw when supported; else legacy) try: - logging.writeToFile(f"Installing plugin using pluginInstaller (zip={zip_path_abs})") - - # Install using pluginInstaller with explicit zip path (avoids cwd races) - try: - pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) - except Exception as install_error: - # Log the full error for debugging - error_msg = str(install_error) - logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}") - # Check if plugin directory exists despite the error - pluginInstalled = '/usr/local/CyberCP/' + plugin_name - if os.path.exists(pluginInstalled): - logging.writeToFile(f"Plugin directory exists despite error, continuing...") - else: - raise Exception(f'Plugin installation failed: {error_msg}') - - # Wait a moment for file system to sync and service to restart - import time - time.sleep(3) # Increased wait time for file system sync - - # Verify plugin was actually installed + _install_plugin_compat(plugin_name, zip_path_abs) + except Exception as install_error: + # Log the full error for debugging + error_msg = str(install_error) + logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}") + # Check if plugin directory exists despite the error pluginInstalled = '/usr/local/CyberCP/' + plugin_name - if not os.path.exists(pluginInstalled): - # Exclude README.md - main CyberPanel repo has it at root - root_files = ['apps.py', 'meta.xml', 'urls.py', 'views.py'] - found_root_files = [f for f in root_files if os.path.exists(os.path.join('/usr/local/CyberCP', f))] - if found_root_files: - raise Exception(f'Plugin installation failed: Files extracted to wrong location. Found {found_root_files} in /usr/local/CyberCP/ root instead of {pluginInstalled}/') - raise Exception(f'Plugin installation failed: {pluginInstalled} does not exist after installation') - - # Sync meta.xml from GitHub raw so version matches store - _sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP') - - logging.writeToFile(f"Plugin {plugin_name} installed successfully") - - # Set plugin to enabled by default after installation - _set_plugin_state(plugin_name, True) - - _ensure_plugin_meta_xml(plugin_name) - return JsonResponse({ - 'success': True, - 'message': f'Plugin {plugin_name} installed successfully from store' - }) - finally: - os.chdir(original_cwd) + if os.path.exists(pluginInstalled): + logging.writeToFile(f"Plugin directory exists despite error, continuing...") + else: + raise Exception(f'Plugin installation failed: {error_msg}') + + # Wait a moment for file system to sync and service to restart + import time + time.sleep(3) # Increased wait time for file system sync + + # Verify plugin was actually installed + pluginInstalled = '/usr/local/CyberCP/' + plugin_name + if not os.path.exists(pluginInstalled): + # Exclude README.md - main CyberPanel repo has it at root + root_files = ['apps.py', 'meta.xml', 'urls.py', 'views.py'] + found_root_files = [f for f in root_files if os.path.exists(os.path.join('/usr/local/CyberCP', f))] + if found_root_files: + raise Exception(f'Plugin installation failed: Files extracted to wrong location. Found {found_root_files} in /usr/local/CyberCP/ root instead of {pluginInstalled}/') + raise Exception(f'Plugin installation failed: {pluginInstalled} does not exist after installation') + + # Sync meta.xml from GitHub raw so version matches store + _sync_meta_xml_from_github(plugin_name, '/usr/local/CyberCP') + + logging.writeToFile(f"Plugin {plugin_name} installed successfully") + + # Set plugin to enabled by default after installation + _set_plugin_state(plugin_name, True) + + _ensure_plugin_meta_xml(plugin_name) + return JsonResponse({ + 'success': True, + 'message': f'Plugin {plugin_name} installed successfully from store' + }) finally: # Cleanup @@ -1870,6 +1907,8 @@ def install_from_store(request, plugin_name): def debug_loaded_plugins(request): """Return which plugins have URL routes loaded and which failed (for diagnosing 404s).""" try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) import pluginHolder.urls as urls_mod loaded = list(getattr(urls_mod, '_loaded_plugins', [])) failed = dict(getattr(urls_mod, '_failed_plugins', {})) @@ -1891,6 +1930,9 @@ def plugin_settings_proxy(request, plugin_name): the plugin was installed after the worker started (dynamic URL list is built at import time). """ mailUtilities.checkHome() + if not user_can_manage_plugins(request): + from django.http import HttpResponseForbidden + return HttpResponseForbidden('You are not authorized to manage plugins.') plugin_path = '/usr/local/CyberCP/' + plugin_name urls_py = os.path.join(plugin_path, 'urls.py') if not plugin_name or not os.path.isdir(plugin_path) or not os.path.exists(urls_py): @@ -1927,7 +1969,7 @@ def plugin_help(request, plugin_name): if not os.path.exists(plugin_path) or not os.path.exists(meta_xml_path): proc = httpProc(request, 'pluginHolder/plugin_not_found.html', { 'plugin_name': plugin_name - }, 'admin') + }, 'managePlugins') return proc.render() # Parse meta.xml @@ -1949,7 +1991,7 @@ def plugin_help(request, plugin_name): logging.writeToFile(f"Error parsing meta.xml for {plugin_name}: {str(e)}") proc = httpProc(request, 'pluginHolder/plugin_not_found.html', { 'plugin_name': plugin_name - }, 'admin') + }, 'managePlugins') return proc.render() # Look for help content files (README.md, CHANGELOG.md, HELP.md, etc.) @@ -2100,7 +2142,7 @@ def plugin_help(request, plugin_name): 'help_content': help_content, } - proc = httpProc(request, 'pluginHolder/plugin_help.html', context, 'admin') + proc = httpProc(request, 'pluginHolder/plugin_help.html', context, 'managePlugins') return proc.render() @csrf_exempt @@ -2122,6 +2164,8 @@ def check_plugin_subscription(request, plugin_name): } """ try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) # Check if user is authenticated if not request.user or not request.user.is_authenticated: return JsonResponse({ diff --git a/pluginInstaller/pluginInstaller.py b/pluginInstaller/pluginInstaller.py index c71e33e56..9b0b98b09 100644 --- a/pluginInstaller/pluginInstaller.py +++ b/pluginInstaller/pluginInstaller.py @@ -4,6 +4,7 @@ import subprocess import shlex import argparse import os +import errno import shutil import time import tempfile @@ -57,6 +58,68 @@ class pluginInstaller: pluginHome = '/usr/local/CyberCP/' + pluginName return os.path.exists(pluginHome + '/enable_migrations') + @staticmethod + def _write_lines_to_protected_file(target_path, lines): + """ + Write UTF-8 lines to a file. Core panel files are often root:root 644; the panel + process may need a privileged copy (lscpd/sudo) to update them. + """ + try: + with open(target_path, 'w', encoding='utf-8') as wf: + wf.writelines(lines) + return + except (PermissionError, OSError) as e: + pluginInstaller.stdOut('Direct write failed for %s: %s' % (target_path, str(e))) + fd, tmp_path = tempfile.mkstemp(prefix='cpwr_', suffix='.txt', dir='/tmp') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as wf: + wf.writelines(lines) + cmd = 'cp %s %s' % (shlex.quote(tmp_path), shlex.quote(target_path)) + if ProcessUtilities.executioner(cmd) == 1: + pluginInstaller.stdOut('Wrote %s via privileged copy' % target_path) + return + except Exception as ex: + pluginInstaller.stdOut('Privileged write failed: %s' % str(ex)) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + raise PermissionError( + 'Cannot write %s. As root: chgrp lscpd %s && chmod 664 %s' + % (target_path, target_path, target_path) + ) + + @staticmethod + def _read_lines_from_protected_file(source_path): + """ + Read UTF-8 lines from a core panel file. If the panel user cannot read the file + (e.g. root:root 600), copy via ProcessUtilities.executioner to /tmp then read. + """ + try: + with open(source_path, 'r', encoding='utf-8') as rf: + return rf.readlines() + except OSError as e: + if e.errno not in (errno.EACCES, errno.EPERM): + raise + pluginInstaller.stdOut('Direct read failed for %s: %s' % (source_path, str(e))) + fd, tmp_path = tempfile.mkstemp(prefix='cprd_', suffix='.txt', dir='/tmp') + os.close(fd) + try: + cmd = 'cp %s %s' % (shlex.quote(source_path), shlex.quote(tmp_path)) + if ProcessUtilities.executioner(cmd) != 1: + raise PermissionError( + 'Privileged read copy failed for %s. As root: chgrp lscpd %s && chmod 640 %s' + % (source_path, source_path, source_path) + ) + with open(tmp_path, 'r', encoding='utf-8') as rf: + return rf.readlines() + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + ### Functions Related to plugin installation. @staticmethod @@ -153,53 +216,79 @@ class pluginInstaller: @staticmethod def upgradingSettingsFile(pluginName): - data = open("/usr/local/CyberCP/CyberCP/settings.py", 'r', encoding='utf-8').readlines() - writeToFile = open("/usr/local/CyberCP/CyberCP/settings.py", 'w', encoding='utf-8') - + settings_path = "/usr/local/CyberCP/CyberCP/settings.py" + data = pluginInstaller._read_lines_from_protected_file(settings_path) + line_plugin = " '" + pluginName + "',\n" + if any(line.strip() in ("'" + pluginName + "',", '"' + pluginName + '",') for line in data): + pluginInstaller.stdOut( + 'Plugin %s already listed in settings.py; skipping INSTALLED_APPS insert.' % pluginName + ) + return + out = [] + inserted = False for items in data: if items.find("'emailPremium',") > -1: - writeToFile.writelines(items) - writeToFile.writelines(" '" + pluginName + "',\n") + out.append(items) + out.append(line_plugin) + inserted = True else: - writeToFile.writelines(items) - - writeToFile.close() + out.append(items) + if not inserted: + out = [] + for items in data: + if "'pluginHolder'," in items or '"pluginHolder",' in items: + out.append(items) + out.append(line_plugin) + inserted = True + else: + out.append(items) + if not inserted: + pluginInstaller.stdOut( + 'Warning: no emailPremium or pluginHolder anchor in settings.py; ' + 'add %r to INSTALLED_APPS manually or upgrade CyberPanel (auto-sync plugins on disk).' + % pluginName + ) + return + pluginInstaller._write_lines_to_protected_file(settings_path, out) @staticmethod def upgradingURLs(pluginName): """ - Add plugin URL pattern to urls.py - Plugin URLs must be inserted BEFORE the generic 'plugins/' line - to ensure proper route matching (more specific routes first) + Legacy: add explicit path('plugins//', ...). Modern CyberPanel uses + pluginHolder.urls for all plugins — skip to avoid duplicate routes and root-only writes. """ - data = open("/usr/local/CyberCP/CyberCP/urls.py", 'r', encoding='utf-8').readlines() - writeToFile = open("/usr/local/CyberCP/CyberCP/urls.py", 'w', encoding='utf-8') + urls_path = "/usr/local/CyberCP/CyberCP/urls.py" + _url_lines = pluginInstaller._read_lines_from_protected_file(urls_path) + content = ''.join(_url_lines) + if "include('pluginHolder.urls')" in content or 'include("pluginHolder.urls")' in content: + pluginInstaller.stdOut( + 'pluginHolder.urls found; skipping per-plugin urls.py line for %s (dynamic routes).' % pluginName + ) + return + data = content.splitlines(keepends=True) + out = [] urlPatternAdded = False - for items in data: - # Insert plugin URL BEFORE the generic 'plugins/' line - # This ensures more specific routes are matched first - if items.find("path('plugins/', include('pluginHolder.urls'))") > -1 or items.find("path(\"plugins/\", include('pluginHolder.urls'))") > -1: + if items.find("path('plugins/', include('pluginHolder.urls'))") > -1 or items.find( + "path(\"plugins/\", include('pluginHolder.urls'))" + ) > -1: if not urlPatternAdded: - writeToFile.writelines(pluginInstaller.getUrlPattern(pluginName)) + out.append(pluginInstaller.getUrlPattern(pluginName)) urlPatternAdded = True - writeToFile.writelines(items) + out.append(items) else: - writeToFile.writelines(items) - - # Fallback: if 'plugins/' line not found, insert after 'manageservices' + out.append(items) if not urlPatternAdded: - pluginInstaller.stdOut(f"Warning: 'plugins/' line not found, using fallback insertion after 'manageservices'") - writeToFile.close() - writeToFile = open("/usr/local/CyberCP/CyberCP/urls.py", 'w', encoding='utf-8') + pluginInstaller.stdOut("Warning: 'plugins/' line not found, using fallback insertion after 'manageservices'") + out = [] for items in data: if items.find("manageservices") > -1: - writeToFile.writelines(items) - writeToFile.writelines(pluginInstaller.getUrlPattern(pluginName)) + out.append(items) + out.append(pluginInstaller.getUrlPattern(pluginName)) + urlPatternAdded = True else: - writeToFile.writelines(items) - - writeToFile.close() + out.append(items) + pluginInstaller._write_lines_to_protected_file(urls_path, out) @staticmethod def informCyberPanel(pluginName): @@ -214,19 +303,19 @@ class pluginInstaller: @staticmethod def addInterfaceLink(pluginName): - data = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'r', encoding='utf-8').readlines() - writeToFile = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'w', encoding='utf-8') - + path_html = "/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html" + data = pluginInstaller._read_lines_from_protected_file(path_html) + out = [] for items in data: if items.find("{# pluginsList #}") > -1: - writeToFile.writelines(items) - writeToFile.writelines(" ") - writeToFile.writelines( - '
  • {% trans "' + pluginName + '" %}
  • \n') + out.append(items) + out.append(" ") + out.append( + '
  • {% trans "' + pluginName + '" %}
  • \n' + ) else: - writeToFile.writelines(items) - - writeToFile.close() + out.append(items) + pluginInstaller._write_lines_to_protected_file(path_html, out) @staticmethod def staticContent(): @@ -469,10 +558,12 @@ class pluginInstaller: def removeFromSettings(pluginName): settings_path = "/usr/local/CyberCP/CyberCP/settings.py" try: - with open(settings_path, 'r', encoding='utf-8') as f: - data = f.readlines() - except (OSError, IOError) as e: - raise Exception(f'Cannot read {settings_path}: {e}. Ensure the panel user can read it.') + data = pluginInstaller._read_lines_from_protected_file(settings_path) + except (OSError, IOError, PermissionError) as e: + raise Exception( + f'Cannot read {settings_path}: {e}. Ensure the panel user can read it ' + f'(e.g. chgrp lscpd {settings_path} && chmod 640 {settings_path}).' + ) in_installed_apps = False out_lines = [] for i, items in enumerate(data): @@ -484,9 +575,8 @@ class pluginInstaller: continue out_lines.append(items) try: - with open(settings_path, 'w', encoding='utf-8') as writeToFile: - writeToFile.writelines(out_lines) - except (OSError, IOError) as e: + pluginInstaller._write_lines_to_protected_file(settings_path, out_lines) + except (OSError, IOError, PermissionError) as e: raise Exception( f'Cannot write {settings_path}: {e}. ' 'Ensure the file is writable by the panel user (e.g. chgrp lscpd ... ; chmod g+w ...).' @@ -496,10 +586,12 @@ class pluginInstaller: def removeFromURLs(pluginName): urls_path = "/usr/local/CyberCP/CyberCP/urls.py" try: - with open(urls_path, 'r', encoding='utf-8') as f: - data = f.readlines() - except (OSError, IOError) as e: - raise Exception(f'Cannot read {urls_path}: {e}.') + data = pluginInstaller._read_lines_from_protected_file(urls_path) + except (OSError, IOError, PermissionError) as e: + raise Exception( + f'Cannot read {urls_path}: {e}. ' + f'As root: chgrp lscpd {urls_path} && chmod 640 {urls_path}' + ) out_lines = [] for items in data: if (f"plugins/{pluginName}/" in items or f"'{pluginName}.urls'" in items or f'"{pluginName}.urls"' in items or @@ -507,9 +599,8 @@ class pluginInstaller: continue out_lines.append(items) try: - with open(urls_path, 'w', encoding='utf-8') as f: - f.writelines(out_lines) - except (OSError, IOError) as e: + pluginInstaller._write_lines_to_protected_file(urls_path, out_lines) + except (OSError, IOError, PermissionError) as e: raise Exception(f'Cannot write {urls_path}: {e}. Ensure the file is writable by the panel user (chgrp lscpd; chmod g+w).') @staticmethod @@ -521,15 +612,14 @@ class pluginInstaller: @staticmethod def removeInterfaceLink(pluginName): - data = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'r', encoding='utf-8').readlines() - writeToFile = open("/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html", 'w', encoding='utf-8') - + path_html = "/usr/local/CyberCP/baseTemplate/templates/baseTemplate/index.html" + data = pluginInstaller._read_lines_from_protected_file(path_html) + out = [] for items in data: if items.find(pluginName) > -1 and items.find('
  • ') > -1: continue - else: - writeToFile.writelines(items) - writeToFile.close() + out.append(items) + pluginInstaller._write_lines_to_protected_file(path_html, out) @staticmethod def removeMigrations(pluginName): diff --git a/public/static/userManagment/userManagment.js b/public/static/userManagment/userManagment.js index 0f0b677af..e11ac8b27 100644 --- a/public/static/userManagment/userManagment.js +++ b/public/static/userManagment/userManagment.js @@ -2,10 +2,61 @@ * Created by usman on 8/5/17. */ +/* Safe notification - use PNotify if available, else fallback to alert */ +function safePNotify(opts) { + if (typeof PNotify !== 'undefined') { + new PNotify(opts); + } else { + var msg = (opts.title || '') + (opts.text ? ': ' + opts.text : ''); + alert(msg || JSON.stringify(opts)); + } +} /* Java script code to create account */ app.controller('createUserCtr', function ($scope, $http) { + // Home directory functionality + $scope.homeDirectories = []; + $scope.selectedHomeDirectory = ''; + $scope.selectedHomeDirectoryInfo = null; + + // Load home directories on page load + $scope.loadHomeDirectories = function() { + var url = '/users/getUserHomeDirectories'; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + $http.post(url, {}, config) + .then(function(response) { + if (response.data && response.data.status === 1) { + $scope.homeDirectories = response.data.directories || []; + } else { + console.error('Error loading home directories:', response.data); + $scope.homeDirectories = []; + } + }) + .catch(function(error) { + console.error('Error loading home directories:', error); + $scope.homeDirectories = []; + }); + }; + + // Update home directory info when selection changes + $scope.updateHomeDirectoryInfo = function() { + if ($scope.selectedHomeDirectory) { + $scope.selectedHomeDirectoryInfo = $scope.homeDirectories.find(function(dir) { + return dir.id == $scope.selectedHomeDirectory; + }); + } else { + $scope.selectedHomeDirectoryInfo = null; + } + }; + + // Initialize home directories + $scope.loadHomeDirectories(); + $scope.acctsLimit = true; $scope.webLimits = true; $scope.userCreated = true; @@ -23,15 +74,19 @@ app.controller('createUserCtr', function ($scope, $http) { $scope.userCreationLoading = false; $scope.combinedLength = true; - - var firstName = $scope.firstName; - var lastName = $scope.lastName; + var firstName = $scope.firstName || ''; + var lastName = $scope.lastName || ''; var email = $scope.email; var selectedACL = $scope.selectedACL; var websitesLimits = $scope.websitesLimits; var userName = $scope.userName; var password = $scope.password; + if (firstName.length + lastName.length > 20) { + $scope.combinedLength = false; + $scope.userCreationLoading = true; + return; + } var url = "/users/submitUserCreation"; @@ -43,12 +98,14 @@ app.controller('createUserCtr', function ($scope, $http) { websitesLimit: websitesLimits, userName: userName, password: password, - securityLevel: $scope.securityLevel + securityLevel: $scope.securityLevel, + selectedHomeDirectory: $scope.selectedHomeDirectory || '' }; var config = { headers: { - 'X-CSRFToken': getCookie('csrftoken') + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': 'application/json' } }; @@ -59,42 +116,31 @@ app.controller('createUserCtr', function ($scope, $http) { if (response.data.createStatus == 1) { - - $scope.userCreated = false; - $scope.userCreationFailed = false; // hide error on success + $scope.userCreated = false; // show success + $scope.userCreationFailed = false; // hide error $scope.couldNotConnect = true; $scope.userCreationLoading = true; - $scope.userName = userName; - - } else { - $scope.acctsLimit = false; $scope.webLimits = false; $scope.userCreated = true; - $scope.userCreationFailed = true; // show error on failure + $scope.userCreationFailed = true; // true = show error alert $scope.couldNotConnect = true; $scope.userCreationLoading = true; - $scope.errorMessage = (response.data && (response.data.error_message || response.data.message || response.data.errorMessage)) || 'Unknown error'; - - } } function cantLoadInitialDatas(response) { - $scope.acctsLimit = false; $scope.webLimits = false; $scope.userCreated = true; - $scope.userCreationFailed = false; // show "Could not connect" instead - $scope.couldNotConnect = false; + $scope.userCreationFailed = false; // hide server error, show connection error instead + $scope.couldNotConnect = false; // show "Could not connect" message $scope.userCreationLoading = true; - - } @@ -125,14 +171,28 @@ app.controller('createUserCtr', function ($scope, $http) { /* Java script code to modify user account */ -app.controller('modifyUser', function ($scope, $http) { +app.controller('modifyUser', function ($scope, $http, $timeout) { - var qrCode = window.qr = new QRious({ - element: document.getElementById('qr'), + var qrEl = document.getElementById('qr'); + var qrCode = window.qr = (qrEl && typeof QRious !== 'undefined') ? new QRious({ + element: qrEl, size: 200, value: 'QRious' - }); + }) : null; + if (!qrCode && qrEl) { + try { window.qr = new QRious({ element: qrEl, size: 200, value: 'QRious' }); } catch (e) { /* ignore */ } + } + $scope.userSearch = ''; + /* Prefer global set by inline script (before Angular); fallback to script tag */ + var list = (typeof window.__CP_ACCT_NAMES !== 'undefined' && Array.isArray(window.__CP_ACCT_NAMES)) + ? window.__CP_ACCT_NAMES + : (function() { + var el = document.getElementById('acctNamesData'); + if (!el || !el.textContent) return []; + try { return JSON.parse(el.textContent); } catch (e) { return []; } + })(); + $scope.acctNamesList = Array.isArray(list) ? list : []; $scope.userModificationLoading = true; $scope.acctDetailsFetched = true; @@ -153,6 +213,223 @@ app.controller('modifyUser', function ($scope, $http) { $scope.qrHidden = true; } }; + + $scope.copySecretKey = function() { + if ($scope.secretKey) { + // Create a temporary textarea element + var tempTextarea = document.createElement('textarea'); + tempTextarea.value = $scope.secretKey; + tempTextarea.style.position = 'fixed'; + tempTextarea.style.opacity = '0'; + document.body.appendChild(tempTextarea); + + // Select and copy the text + tempTextarea.select(); + tempTextarea.setSelectionRange(0, 99999); // For mobile devices + + try { + document.execCommand('copy'); + // Show success feedback (you can add a toast notification here if available) + alert('Secret key copied to clipboard!'); + } catch (err) { + alert('Failed to copy secret key. Please copy it manually.'); + } + + // Remove the temporary element + document.body.removeChild(tempTextarea); + } + }; + + $scope.regenerateSecret = function() { + if (!$scope.accountUsername) { + alert('Please select a user first.'); + return; + } + + if (!confirm('Are you sure you want to regenerate the 2FA secret? This will generate a new secret key and you will need to update your authenticator app.')) { + return; + } + + var url = "/users/regenerateTwoFASecret"; + var data = { + accountUsername: $scope.accountUsername + }; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + + $http.post(url, data, config).then(function(response) { + if (response.data.status === 1) { + // Update the secret key and formatted version + $scope.secretKey = response.data.secretKey; + $scope.formattedSecretKey = response.data.secretKey.match(/.{1,4}/g).join(' '); + + // Update the QR code with new provisioning URI + if (qrCode) qrCode.set({ value: response.data.otpauth }); + + // Show success message + alert('2FA secret has been successfully regenerated! Please update your authenticator app with the new QR code or secret key.'); + } else { + alert('Error regenerating 2FA secret: ' + response.data.error_message); + } + }, function(error) { + console.error('Error regenerating 2FA secret:', error); + alert('Failed to regenerate 2FA secret. Please try again.'); + }); + }; + + // WebAuthn Functions + $scope.loadWebAuthnData = function() { + if (!$scope.accountUsername) return; + $scope.webauthnDataLoaded = false; + var url = '/webauthn/credentials/' + $scope.accountUsername + '/'; + $http.get(url).then(function(response) { + if (response.data.success) { + $scope.webauthnCredentials = response.data.credentials || []; + $scope.webauthnEnabled = response.data.settings.enabled; + $scope.webauthnRequirePasskey = response.data.settings.require_passkey; + $scope.webauthnAllowMultiple = response.data.settings.allow_multiple_credentials; + $scope.webauthnMaxCredentials = response.data.settings.max_credentials; + $scope.canAddCredential = !!response.data.settings.can_add_credential; + $scope.webauthnDataLoaded = true; + } else { + $scope.canAddCredential = true; + } + }, function(error) { + console.error('Error loading WebAuthn data:', error); + $scope.canAddCredential = true; + $scope.webauthnCredentials = []; + }); + }; + + $scope.toggleWebAuthn = function() { + if ($scope.webauthnEnabled) { + $scope.loadWebAuthnData(); + } else { + $scope.webauthnCredentials = []; + $scope.canAddCredential = true; + $scope.webauthnDataLoaded = false; + } + }; + + /* Inline passkey UX like diabetes.newstargeted.com/profile?tab=2fa: name input + button + message area, no modal */ + $scope.newPasskeyName = ''; + $scope.webauthnMessage = ''; + $scope.webauthnMessageError = false; + $scope.registerPasskeyLoading = false; + + $scope.registerNewPasskey = function() { + if (!$scope.accountUsername) { + $scope.webauthnMessage = 'Please select a user account first.'; + $scope.webauthnMessageError = true; + return; + } + if (typeof window.cyberPanelWebAuthn === 'undefined') { + $scope.webauthnMessage = 'WebAuthn script not loaded. Refresh the page (Ctrl+F5) and try again.'; + $scope.webauthnMessageError = true; + return; + } + if (typeof window.cyberPanelWebAuthn.isSupported !== 'function' || !window.cyberPanelWebAuthn.isSupported()) { + $scope.webauthnMessage = 'WebAuthn is not supported in this browser.'; + $scope.webauthnMessageError = true; + return; + } + var name = ($scope.newPasskeyName || '').trim() || 'Security key'; + $scope.webauthnMessage = ''; + $scope.webauthnMessageError = false; + $scope.registerPasskeyLoading = true; + var username = $scope.accountUsername; + window.cyberPanelWebAuthn.registerPasskey(username, name, { silent: true }) + .then(function(response) { + if (response && response.success) { + $scope.webauthnMessage = 'Passkey registered successfully.'; + $scope.webauthnMessageError = false; + $scope.newPasskeyName = ''; + $timeout(function() { $scope.loadWebAuthnData(); }, 0); + } else { + $scope.webauthnMessage = (response && response.error) ? response.error : 'Registration failed.'; + $scope.webauthnMessageError = true; + } + }) + .catch(function(error) { + var msg = (error && error.message) ? error.message : 'Passkey registration failed.'; + if (error && error.name === 'NotAllowedError') msg = 'Registration was cancelled or timed out.'; + $scope.webauthnMessage = msg; + $scope.webauthnMessageError = true; + console.error('Error registering passkey:', error); + }) + .finally(function() { + $scope.registerPasskeyLoading = false; + if (!$scope.$$phase && !$scope.$root.$$phase) { try { $scope.$apply(); } catch (e) { /* already applied */ } } + }); + }; + + $scope.deleteCredential = function(credentialId) { + if (!confirm('Are you sure you want to delete this passkey?')) return; + + if (!window.cyberPanelWebAuthn) { + alert('WebAuthn is not supported in this browser'); + return; + } + + window.cyberPanelWebAuthn.deleteCredential($scope.accountUsername, credentialId) + .then(function(response) { + if (response.success) { + $scope.loadWebAuthnData(); + $scope.$apply(); + } + }) + .catch(function(error) { + console.error('Error deleting credential:', error); + }); + }; + + $scope.updateCredentialName = function(credentialId, newName) { + if (!window.cyberPanelWebAuthn) return; + + window.cyberPanelWebAuthn.updateCredentialName($scope.accountUsername, credentialId, newName) + .then(function(response) { + if (response.success) { + $scope.loadWebAuthnData(); + $scope.$apply(); + } + }) + .catch(function(error) { + console.error('Error updating credential name:', error); + }); + }; + + $scope.refreshCredentials = function() { + $scope.loadWebAuthnData(); + }; + + $scope.saveWebAuthnSettings = function() { + if (!window.cyberPanelWebAuthn) { + alert('WebAuthn is not supported in this browser'); + return; + } + + var settings = { + enabled: $scope.webauthnEnabled, + require_passkey: $scope.webauthnRequirePasskey, + allow_multiple_credentials: $scope.webauthnAllowMultiple, + max_credentials: $scope.webauthnMaxCredentials, + timeout_seconds: $scope.webauthnTimeout + }; + + window.cyberPanelWebAuthn.updateSettings($scope.accountUsername, settings) + .then(function(response) { + if (response.success) { + $scope.loadWebAuthnData(); + $scope.$apply(); + } + }) + .catch(function(error) { + console.error('Error updating WebAuthn settings:', error); + }); + }; $scope.fetchUserDetails = function () { @@ -191,11 +468,26 @@ app.controller('modifyUser', function ($scope, $http) { $scope.securityLevel = userDetails.securityLevel; $scope.currentSecurityLevel = userDetails.securityLevel; $scope.twofa = Boolean(userDetails.twofa); + + // Format secret key with spaces for better readability + if (userDetails.secretKey) { + $scope.secretKey = userDetails.secretKey; + $scope.formattedSecretKey = userDetails.secretKey.match(/.{1,4}/g).join(' '); + } + + // Initialize WebAuthn settings + $scope.webauthnEnabled = false; + $scope.webauthnRequirePasskey = false; + $scope.webauthnAllowMultiple = true; + $scope.webauthnMaxCredentials = 10; + $scope.webauthnTimeout = 60; + $scope.webauthnCredentials = []; + $scope.canAddCredential = true; + $scope.webauthnDataLoaded = false; + // Load WebAuthn settings and credentials + $scope.loadWebAuthnData(); - qrCode.set({ - value: userDetails.otpauth - }); - + if (qrCode) qrCode.set({ value: userDetails.otpauth }); $scope.userModificationLoading = true; $scope.acctDetailsFetched = false; @@ -214,7 +506,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.userModified = true; $scope.canotModifyUser = false; // hide modify error (only fetch failed) $scope.couldNotConnect = true; - $scope.canotFetchDetails = true; // show fetch error + $scope.canotFetchDetails = true; // show fetch error on failure $scope.detailsFetched = false; @@ -249,7 +541,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.userModificationLoading = false; $scope.acctDetailsFetched = false; $scope.userModified = true; - $scope.canotModifyUser = false; // hide error until we know result + $scope.canotModifyUser = false; // hide modify error until we know result $scope.couldNotConnect = true; $scope.canotFetchDetails = true; $scope.detailsFetched = true; @@ -273,18 +565,28 @@ app.controller('modifyUser', function ($scope, $http) { firstName: firstName, lastName: lastName, email: email, - passwordByPass: password, securityLevel: $scope.securityLevel, twofa: $scope.twofa }; + // Only include password if it's provided and not empty + if (password && password.trim()) { + data.passwordByPass = password; + } + var config = { headers: { 'X-CSRFToken': getCookie('csrftoken') } }; - $http.post(url, data, config).then(ListInitialDatas, cantLoadInitialDatas); + $http.post(url, data, config).then(function(response) { + ListInitialDatas(response); + // Save WebAuthn settings after successful user modification (only if WebAuthn script loaded) + if (response.data.saveStatus == 1 && window.cyberPanelWebAuthn) { + $scope.saveWebAuthnSettings(); + } + }, cantLoadInitialDatas); function ListInitialDatas(response) { @@ -297,7 +599,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.userModified = false; $scope.canotModifyUser = false; // hide modify error on success $scope.couldNotConnect = true; - $scope.canotFetchDetails = true; + $scope.canotFetchDetails = false; // hide "Cannot fetch details" on save success $scope.detailsFetched = true; $scope.userAccountsLimit = true; $scope.accountTypeView = true; @@ -312,7 +614,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.userModificationLoading = true; $scope.acctDetailsFetched = false; $scope.userModified = true; - $scope.canotModifyUser = true; // show modify error + $scope.canotModifyUser = true; // show modify error on failure $scope.couldNotConnect = true; $scope.canotFetchDetails = true; $scope.detailsFetched = true; @@ -335,7 +637,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.couldNotConnect = false; $scope.canotFetchDetails = true; $scope.detailsFetched = true; - + $scope.errorMessage = (response && response.data && (response.data.error_message || response.data.message || response.data.errorMessage)) || 'Unknown error'; } @@ -461,6 +763,10 @@ app.controller('deleteUser', function ($scope, $http) { app.controller('createACLCTRL', function ($scope, $http) { + $scope.aclCreated = true; + $scope.aclCreationFailed = false; // false = don't show error alert on load + $scope.couldNotConnect = true; + $scope.aclLoading = true; $scope.makeAdmin = false; @@ -629,14 +935,14 @@ app.controller('createACLCTRL', function ($scope, $http) { $scope.aclLoading = true; if (response.data.status === 1) { - new PNotify({ + safePNotify({ title: 'Success!', text: 'ACL Successfully created.', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -651,7 +957,7 @@ app.controller('createACLCTRL', function ($scope, $http) { $scope.aclLoading = false; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -781,7 +1087,7 @@ app.controller('createACLCTRL', function ($scope, $http) { // Email Management $scope.createEmail = true; - $scope.listEmails = True; + $scope.listEmails = true; $scope.deleteEmail = true; $scope.emailForwarding = true; $scope.changeEmailPassword = true; @@ -850,14 +1156,14 @@ app.controller('deleteACTCTRL', function ($scope, $http) { $scope.aclLoading = true; if (response.data.status === 1) { - new PNotify({ + safePNotify({ title: 'Success!', text: 'ACL Successfully deleted.', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -869,7 +1175,7 @@ app.controller('deleteACTCTRL', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.aclLoading = true; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -913,7 +1219,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { if (response.data.status === 1) { - new PNotify({ + safePNotify({ title: 'Success!', text: 'Current settings successfully fetched', type: 'success' @@ -993,7 +1299,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { $scope.mailServerSSL = Boolean(response.data.mailServerSSL); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -1006,7 +1312,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { $scope.aclLoading = false; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1108,14 +1414,14 @@ app.controller('modifyACLCtrl', function ($scope, $http) { $scope.aclLoading = true; if (response.data.status === 1) { - new PNotify({ + safePNotify({ title: 'Success!', text: 'ACL Successfully modified.', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -1130,7 +1436,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { $scope.aclLoading = false; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1260,7 +1566,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { // Email Management $scope.createEmail = true; - $scope.listEmails = True; + $scope.listEmails = true; $scope.deleteEmail = true; $scope.emailForwarding = true; $scope.changeEmailPassword = true; @@ -1322,14 +1628,14 @@ app.controller('changeUserACLCTRL', function ($scope, $http) { $scope.aclLoading = true; if (response.data.status === 1) { - new PNotify({ + safePNotify({ title: 'Success!', text: 'ACL Successfully changed.', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -1341,7 +1647,7 @@ app.controller('changeUserACLCTRL', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.aclLoading = true; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1384,14 +1690,14 @@ app.controller('resellerCenterCTRL', function ($scope, $http) { $scope.aclLoading = true; if (response.data.status === 1) { - new PNotify({ + safePNotify({ title: 'Success!', text: 'Changes successfully applied!', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -1403,7 +1709,7 @@ app.controller('resellerCenterCTRL', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.aclLoading = true; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1453,14 +1759,14 @@ app.controller('apiAccessCTRL', function ($scope, $http) { if (response.data.status === 1) { $scope.apiAccessDropDown = true; - new PNotify({ + safePNotify({ title: 'Success!', text: 'Changes successfully applied!', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.error_message, type: 'error' @@ -1472,7 +1778,7 @@ app.controller('apiAccessCTRL', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.cyberpanelLoading = true; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1486,6 +1792,147 @@ app.controller('apiAccessCTRL', function ($scope, $http) { }); /* Java script code for api access */ +/* Java script code for api users list */ +app.controller('apiUsersCTRL', function ($scope, $http) { + $scope.apiUsers = []; + $scope.filteredUsers = []; + $scope.searchQuery = ''; + $scope.apiUsersLoading = true; + + $scope.loadAPIUsers = function() { + $scope.apiUsersLoading = false; + + var url = "/users/fetchAPIUsers"; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + + $http.get(url, config).then(loadAPIUsersSuccess, loadAPIUsersError); + }; + + function loadAPIUsersSuccess(response) { + $scope.apiUsersLoading = true; + + if (response.data.status === 1) { + $scope.apiUsers = response.data.users; + $scope.filteredUsers = response.data.users; + + safePNotify({ + title: 'Success!', + text: 'API users loaded successfully', + type: 'success' + }); + } else { + safePNotify({ + title: 'Error!', + text: response.data.error_message, + type: 'error' + }); + } + } + + function loadAPIUsersError(response) { + $scope.apiUsersLoading = true; + safePNotify({ + title: 'Error!', + text: 'Could not load API users. Please refresh the page.', + type: 'error' + }); + } + + $scope.searchUsers = function() { + if (!$scope.searchQuery || $scope.searchQuery.trim() === '') { + $scope.filteredUsers = $scope.apiUsers; + return; + } + + var query = $scope.searchQuery.toLowerCase(); + $scope.filteredUsers = $scope.apiUsers.filter(function(user) { + return user.userName.toLowerCase().includes(query) || + user.firstName.toLowerCase().includes(query) || + user.lastName.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) || + user.aclName.toLowerCase().includes(query); + }); + }; + + $scope.clearSearch = function() { + $scope.searchQuery = ''; + $scope.filteredUsers = $scope.apiUsers; + }; + + $scope.viewUserDetails = function(user) { + safePNotify({ + title: 'User Details', + text: 'Username: ' + user.userName + '
    ' + + 'Full Name: ' + user.firstName + ' ' + user.lastName + '
    ' + + 'Email: ' + user.email + '
    ' + + 'ACL: ' + user.aclName + '
    ' + + 'Token Status: ' + user.tokenStatus + '
    ' + + 'State: ' + user.state, + type: 'info', + styling: 'bootstrap3', + delay: 10000 + }); + }; + + $scope.disableAPI = function(user) { + if (confirm('Are you sure you want to disable API access for ' + user.userName + '?')) { + $scope.apiUsersLoading = false; + + var url = "/users/saveChangesAPIAccess"; + var data = { + accountUsername: user.userName, + access: 'Disable' + }; + var config = { + headers: { + 'X-CSRFToken': getCookie('csrftoken') + } + }; + + $http.post(url, data, config).then(disableAPISuccess, disableAPIError); + } + }; + + function disableAPISuccess(response) { + $scope.apiUsersLoading = true; + + if (response.data.status === 1) { + // Remove user from the list + $scope.apiUsers = $scope.apiUsers.filter(function(u) { + return u.userName !== response.data.accountUsername; + }); + $scope.filteredUsers = $scope.apiUsers; + + safePNotify({ + title: 'Success!', + text: 'API access disabled for ' + response.data.accountUsername, + type: 'success' + }); + } else { + safePNotify({ + title: 'Error!', + text: response.data.error_message, + type: 'error' + }); + } + } + + function disableAPIError(response) { + $scope.apiUsersLoading = true; + safePNotify({ + title: 'Error!', + text: 'Could not disable API access. Please try again.', + type: 'error' + }); + } + + // Load API users when controller initializes + $scope.loadAPIUsers(); +}); /* Java script code to list table users */ @@ -1532,7 +1979,11 @@ app.controller('listTableUsers', function ($scope, $http) { var UserToDelete; - $scope.populateCurrentRecords = function () { + /** + * Reload the user table from the server. + * @param {boolean} [suppressSuccessNotify=false] - When true, no success toast (avoids double popups after delete/edit/suspend when the caller already notified). + */ + $scope.populateCurrentRecords = function (suppressSuccessNotify) { $scope.cyberpanelLoading = false; url = "/users/fetchTableUsers"; @@ -1556,14 +2007,16 @@ app.controller('listTableUsers', function ($scope, $http) { $scope.records = JSON.parse(response.data.data); - new PNotify({ - title: 'Success!', - text: 'Users successfully fetched!', - type: 'success' - }); + if (!suppressSuccessNotify) { + safePNotify({ + title: 'Success!', + text: 'Users successfully fetched!', + type: 'success' + }); + } } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.error_message, type: 'error' @@ -1574,7 +2027,7 @@ app.controller('listTableUsers', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.cyberpanelLoading = true; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1582,7 +2035,8 @@ app.controller('listTableUsers', function ($scope, $http) { } }; - $scope.populateCurrentRecords(); + /* Initial load: silent (no "fetched" toast on every page open) */ + $scope.populateCurrentRecords(true); $scope.deleteUserInitial = function (name){ @@ -1612,9 +2066,9 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { $scope.cyberpanelLoading = true; if (response.data.deleteStatus === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('deleteModal'); - new PNotify({ + safePNotify({ title: 'Success!', text: 'Users successfully deleted!', type: 'success' @@ -1622,7 +2076,7 @@ app.controller('listTableUsers', function ($scope, $http) { } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.error_message, type: 'error' @@ -1636,7 +2090,7 @@ app.controller('listTableUsers', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.cyberpanelLoading = false; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1649,10 +2103,8 @@ app.controller('listTableUsers', function ($scope, $http) { }; $scope.editInitial = function (name) { - $scope.name = name; showModalById('editModal'); - }; $scope.saveResellerChanges = function () { @@ -1678,16 +2130,16 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('editModal'); - new PNotify({ + safePNotify({ title: 'Success!', text: 'Changes successfully applied!', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -1698,7 +2150,7 @@ app.controller('listTableUsers', function ($scope, $http) { } function cantLoadInitialDatas(response) { - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1732,16 +2184,16 @@ app.controller('listTableUsers', function ($scope, $http) { $scope.cyberpanelLoading = true; if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('editModal'); - new PNotify({ + safePNotify({ title: 'Success!', text: 'ACL Successfully changed.', type: 'success' }); } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.errorMessage, type: 'error' @@ -1753,7 +2205,7 @@ app.controller('listTableUsers', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.aclLoading = true; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' @@ -1764,6 +2216,7 @@ app.controller('listTableUsers', function ($scope, $http) { }; $scope.controlUserState = function (userName, state) { + $scope.cyberpanelLoading = false; var url = "/users/controlUserState"; @@ -1785,8 +2238,8 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { $scope.cyberpanelLoading = true; if (response.data.status === 1) { - $scope.populateCurrentRecords(); - new PNotify({ + $scope.populateCurrentRecords(true); + safePNotify({ title: 'Success!', text: 'Action successfully started.', type: 'success' @@ -1794,7 +2247,7 @@ app.controller('listTableUsers', function ($scope, $http) { } else { - new PNotify({ + safePNotify({ title: 'Error!', text: response.data.error_message, type: 'error' @@ -1808,7 +2261,7 @@ app.controller('listTableUsers', function ($scope, $http) { function cantLoadInitialDatas(response) { $scope.cyberpanelLoading = false; - new PNotify({ + safePNotify({ title: 'Error!', text: 'Could not connect to server, please refresh this page.', type: 'error' diff --git a/static/userManagment/userManagment.js b/static/userManagment/userManagment.js index 071dcec34..e11ac8b27 100644 --- a/static/userManagment/userManagment.js +++ b/static/userManagment/userManagment.js @@ -171,14 +171,28 @@ app.controller('createUserCtr', function ($scope, $http) { /* Java script code to modify user account */ -app.controller('modifyUser', function ($scope, $http) { +app.controller('modifyUser', function ($scope, $http, $timeout) { - var qrCode = window.qr = new QRious({ - element: document.getElementById('qr'), + var qrEl = document.getElementById('qr'); + var qrCode = window.qr = (qrEl && typeof QRious !== 'undefined') ? new QRious({ + element: qrEl, size: 200, value: 'QRious' - }); + }) : null; + if (!qrCode && qrEl) { + try { window.qr = new QRious({ element: qrEl, size: 200, value: 'QRious' }); } catch (e) { /* ignore */ } + } + $scope.userSearch = ''; + /* Prefer global set by inline script (before Angular); fallback to script tag */ + var list = (typeof window.__CP_ACCT_NAMES !== 'undefined' && Array.isArray(window.__CP_ACCT_NAMES)) + ? window.__CP_ACCT_NAMES + : (function() { + var el = document.getElementById('acctNamesData'); + if (!el || !el.textContent) return []; + try { return JSON.parse(el.textContent); } catch (e) { return []; } + })(); + $scope.acctNamesList = Array.isArray(list) ? list : []; $scope.userModificationLoading = true; $scope.acctDetailsFetched = true; @@ -253,9 +267,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.formattedSecretKey = response.data.secretKey.match(/.{1,4}/g).join(' '); // Update the QR code with new provisioning URI - qrCode.set({ - value: response.data.otpauth - }); + if (qrCode) qrCode.set({ value: response.data.otpauth }); // Show success message alert('2FA secret has been successfully regenerated! Please update your authenticator app with the new QR code or secret key.'); @@ -271,20 +283,24 @@ app.controller('modifyUser', function ($scope, $http) { // WebAuthn Functions $scope.loadWebAuthnData = function() { if (!$scope.accountUsername) return; - + $scope.webauthnDataLoaded = false; var url = '/webauthn/credentials/' + $scope.accountUsername + '/'; - $http.get(url).then(function(response) { if (response.data.success) { - $scope.webauthnCredentials = response.data.credentials; + $scope.webauthnCredentials = response.data.credentials || []; $scope.webauthnEnabled = response.data.settings.enabled; $scope.webauthnRequirePasskey = response.data.settings.require_passkey; $scope.webauthnAllowMultiple = response.data.settings.allow_multiple_credentials; $scope.webauthnMaxCredentials = response.data.settings.max_credentials; - $scope.canAddCredential = response.data.settings.can_add_credential; + $scope.canAddCredential = !!response.data.settings.can_add_credential; + $scope.webauthnDataLoaded = true; + } else { + $scope.canAddCredential = true; } }, function(error) { console.error('Error loading WebAuthn data:', error); + $scope.canAddCredential = true; + $scope.webauthnCredentials = []; }); }; @@ -294,27 +310,59 @@ app.controller('modifyUser', function ($scope, $http) { } else { $scope.webauthnCredentials = []; $scope.canAddCredential = true; + $scope.webauthnDataLoaded = false; } }; + /* Inline passkey UX like diabetes.newstargeted.com/profile?tab=2fa: name input + button + message area, no modal */ + $scope.newPasskeyName = ''; + $scope.webauthnMessage = ''; + $scope.webauthnMessageError = false; + $scope.registerPasskeyLoading = false; + $scope.registerNewPasskey = function() { - if (!window.cyberPanelWebAuthn) { - alert('WebAuthn is not supported in this browser'); + if (!$scope.accountUsername) { + $scope.webauthnMessage = 'Please select a user account first.'; + $scope.webauthnMessageError = true; return; } - - var credentialName = prompt('Enter a name for this passkey:', 'Passkey ' + new Date().toLocaleDateString()); - if (!credentialName) return; - - window.cyberPanelWebAuthn.registerPasskey($scope.accountUsername, credentialName) + if (typeof window.cyberPanelWebAuthn === 'undefined') { + $scope.webauthnMessage = 'WebAuthn script not loaded. Refresh the page (Ctrl+F5) and try again.'; + $scope.webauthnMessageError = true; + return; + } + if (typeof window.cyberPanelWebAuthn.isSupported !== 'function' || !window.cyberPanelWebAuthn.isSupported()) { + $scope.webauthnMessage = 'WebAuthn is not supported in this browser.'; + $scope.webauthnMessageError = true; + return; + } + var name = ($scope.newPasskeyName || '').trim() || 'Security key'; + $scope.webauthnMessage = ''; + $scope.webauthnMessageError = false; + $scope.registerPasskeyLoading = true; + var username = $scope.accountUsername; + window.cyberPanelWebAuthn.registerPasskey(username, name, { silent: true }) .then(function(response) { - if (response.success) { - $scope.loadWebAuthnData(); - $scope.$apply(); + if (response && response.success) { + $scope.webauthnMessage = 'Passkey registered successfully.'; + $scope.webauthnMessageError = false; + $scope.newPasskeyName = ''; + $timeout(function() { $scope.loadWebAuthnData(); }, 0); + } else { + $scope.webauthnMessage = (response && response.error) ? response.error : 'Registration failed.'; + $scope.webauthnMessageError = true; } }) .catch(function(error) { + var msg = (error && error.message) ? error.message : 'Passkey registration failed.'; + if (error && error.name === 'NotAllowedError') msg = 'Registration was cancelled or timed out.'; + $scope.webauthnMessage = msg; + $scope.webauthnMessageError = true; console.error('Error registering passkey:', error); + }) + .finally(function() { + $scope.registerPasskeyLoading = false; + if (!$scope.$$phase && !$scope.$root.$$phase) { try { $scope.$apply(); } catch (e) { /* already applied */ } } }); }; @@ -435,14 +483,11 @@ app.controller('modifyUser', function ($scope, $http) { $scope.webauthnTimeout = 60; $scope.webauthnCredentials = []; $scope.canAddCredential = true; - + $scope.webauthnDataLoaded = false; // Load WebAuthn settings and credentials $scope.loadWebAuthnData(); - qrCode.set({ - value: userDetails.otpauth - }); - + if (qrCode) qrCode.set({ value: userDetails.otpauth }); $scope.userModificationLoading = true; $scope.acctDetailsFetched = false; @@ -537,8 +582,8 @@ app.controller('modifyUser', function ($scope, $http) { $http.post(url, data, config).then(function(response) { ListInitialDatas(response); - // Save WebAuthn settings after successful user modification - if (response.data.saveStatus == 1) { + // Save WebAuthn settings after successful user modification (only if WebAuthn script loaded) + if (response.data.saveStatus == 1 && window.cyberPanelWebAuthn) { $scope.saveWebAuthnSettings(); } }, cantLoadInitialDatas); @@ -554,7 +599,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.userModified = false; $scope.canotModifyUser = false; // hide modify error on success $scope.couldNotConnect = true; - $scope.canotFetchDetails = true; + $scope.canotFetchDetails = false; // hide "Cannot fetch details" on save success $scope.detailsFetched = true; $scope.userAccountsLimit = true; $scope.accountTypeView = true; @@ -592,7 +637,7 @@ app.controller('modifyUser', function ($scope, $http) { $scope.couldNotConnect = false; $scope.canotFetchDetails = true; $scope.detailsFetched = true; - + $scope.errorMessage = (response && response.data && (response.data.error_message || response.data.message || response.data.errorMessage)) || 'Unknown error'; } @@ -1934,7 +1979,11 @@ app.controller('listTableUsers', function ($scope, $http) { var UserToDelete; - $scope.populateCurrentRecords = function () { + /** + * Reload the user table from the server. + * @param {boolean} [suppressSuccessNotify=false] - When true, no success toast (avoids double popups after delete/edit/suspend when the caller already notified). + */ + $scope.populateCurrentRecords = function (suppressSuccessNotify) { $scope.cyberpanelLoading = false; url = "/users/fetchTableUsers"; @@ -1958,11 +2007,13 @@ app.controller('listTableUsers', function ($scope, $http) { $scope.records = JSON.parse(response.data.data); - safePNotify({ - title: 'Success!', - text: 'Users successfully fetched!', - type: 'success' - }); + if (!suppressSuccessNotify) { + safePNotify({ + title: 'Success!', + text: 'Users successfully fetched!', + type: 'success' + }); + } } else { safePNotify({ @@ -1984,7 +2035,8 @@ app.controller('listTableUsers', function ($scope, $http) { } }; - $scope.populateCurrentRecords(); + /* Initial load: silent (no "fetched" toast on every page open) */ + $scope.populateCurrentRecords(true); $scope.deleteUserInitial = function (name){ @@ -2014,7 +2066,7 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { $scope.cyberpanelLoading = true; if (response.data.deleteStatus === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('deleteModal'); safePNotify({ title: 'Success!', @@ -2078,7 +2130,7 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('editModal'); safePNotify({ title: 'Success!', @@ -2132,7 +2184,7 @@ app.controller('listTableUsers', function ($scope, $http) { $scope.cyberpanelLoading = true; if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('editModal'); safePNotify({ title: 'Success!', @@ -2186,7 +2238,7 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { $scope.cyberpanelLoading = true; if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); safePNotify({ title: 'Success!', text: 'Action successfully started.', diff --git a/userManagment/homeDirectoryManager.py b/userManagment/homeDirectoryManager.py index d81821a0c..d45bb66f6 100644 --- a/userManagment/homeDirectoryManager.py +++ b/userManagment/homeDirectoryManager.py @@ -51,7 +51,7 @@ class HomeDirectoryManager: return home_dirs except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error detecting home directories: {str(e)}") + logging.writeToFile(f"Error detecting home directories: {str(e)}") return [] @staticmethod @@ -103,7 +103,7 @@ class HomeDirectoryManager: return home_dirs[0]['path'] except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error selecting best home directory: {str(e)}") + logging.writeToFile(f"Error selecting best home directory: {str(e)}") return '/home' @staticmethod @@ -134,7 +134,7 @@ class HomeDirectoryManager: return True except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error creating user directory: {str(e)}") + logging.writeToFile(f"Error creating user directory: {str(e)}") return False @staticmethod @@ -154,7 +154,7 @@ class HomeDirectoryManager: return True except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error setting ownership: {str(e)}") + logging.writeToFile(f"Error setting ownership: {str(e)}") return False @staticmethod @@ -188,7 +188,7 @@ class HomeDirectoryManager: return True, "User migrated successfully" except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error migrating user: {str(e)}") + logging.writeToFile(f"Error migrating user: {str(e)}") return False, str(e) @staticmethod @@ -217,7 +217,7 @@ class HomeDirectoryManager: return stats except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error getting home directory stats: {str(e)}") + logging.writeToFile(f"Error getting home directory stats: {str(e)}") return None @staticmethod diff --git a/userManagment/homeDirectoryViews.py b/userManagment/homeDirectoryViews.py index 96f1403d4..70f99533a 100644 --- a/userManagment/homeDirectoryViews.py +++ b/userManagment/homeDirectoryViews.py @@ -35,7 +35,7 @@ def loadHomeDirectoryManagement(request): return proc.render() except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error loading home directory management: {str(e)}") + logging.writeToFile(f"Error loading home directory management: {str(e)}") return ACLManager.loadError() def detectHomeDirectories(request): @@ -71,7 +71,7 @@ def detectHomeDirectories(request): }) except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error detecting home directories: {str(e)}") + logging.writeToFile(f"Error detecting home directories: {str(e)}") return JsonResponse({'status': 0, 'error_message': str(e)}) def updateHomeDirectory(request): @@ -109,7 +109,7 @@ def updateHomeDirectory(request): return JsonResponse({'status': 0, 'error_message': 'Home directory not found'}) except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error updating home directory: {str(e)}") + logging.writeToFile(f"Error updating home directory: {str(e)}") return JsonResponse({'status': 0, 'error_message': str(e)}) def deleteHomeDirectory(request): @@ -149,7 +149,7 @@ def deleteHomeDirectory(request): return JsonResponse({'status': 0, 'error_message': 'Home directory not found'}) except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error deleting home directory: {str(e)}") + logging.writeToFile(f"Error deleting home directory: {str(e)}") return JsonResponse({'status': 0, 'error_message': str(e)}) def getHomeDirectoryStats(request): @@ -165,7 +165,7 @@ def getHomeDirectoryStats(request): return JsonResponse({'status': 1, 'stats': stats}) except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error getting home directory stats: {str(e)}") + logging.writeToFile(f"Error getting home directory stats: {str(e)}") return JsonResponse({'status': 0, 'error_message': str(e)}) def getUserHomeDirectories(request): @@ -174,7 +174,9 @@ def getUserHomeDirectories(request): userID = request.session['userID'] currentACL = ACLManager.loadedACL(userID) - if currentACL['admin'] != 1 and currentACL['createNewUser'] != 1: + # Same visibility as create user page: admins, ACL editors, and users allowed to create accounts + if (currentACL['admin'] != 1 and currentACL['createNewUser'] != 1 + and currentACL.get('changeUserACL', 0) != 1): return JsonResponse({'status': 0, 'error_message': 'Unauthorized access'}) # Get active home directories (tables home_directories / user_home_mappings may not exist yet) @@ -195,7 +197,7 @@ def getUserHomeDirectories(request): return JsonResponse({'status': 1, 'directories': directories}) except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error getting user home directories: {str(e)}") + logging.writeToFile(f"Error getting user home directories: {str(e)}") # If tables don't exist (e.g. user_home_mappings), return empty list so Modify Website still works return JsonResponse({'status': 1, 'directories': []}) @@ -251,5 +253,5 @@ def migrateUser(request): return JsonResponse({'status': 0, 'error_message': 'Target home directory not found'}) except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error migrating user: {str(e)}") + logging.writeToFile(f"Error migrating user: {str(e)}") return JsonResponse({'status': 0, 'error_message': str(e)}) diff --git a/userManagment/static/userManagment/userManagment.js b/userManagment/static/userManagment/userManagment.js index 8a0b21649..3b90ed8ea 100644 --- a/userManagment/static/userManagment/userManagment.js +++ b/userManagment/static/userManagment/userManagment.js @@ -773,6 +773,7 @@ app.controller('createACLCTRL', function ($scope, $http) { // $scope.versionManagement = false; + $scope.managePlugins = false; // User Management @@ -854,6 +855,7 @@ app.controller('createACLCTRL', function ($scope, $http) { // versionManagement: $scope.versionManagement, + managePlugins: $scope.managePlugins, // User Management @@ -977,6 +979,7 @@ app.controller('createACLCTRL', function ($scope, $http) { // $scope.versionManagement = true; + $scope.managePlugins = true; // User Management @@ -1048,6 +1051,7 @@ app.controller('createACLCTRL', function ($scope, $http) { // $scope.versionManagement = false; + $scope.managePlugins = false; // User Management @@ -1232,6 +1236,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { // $scope.versionManagement = Boolean(response.data.versionManagement); + $scope.managePlugins = Boolean(response.data.managePlugins); // User Management @@ -1333,6 +1338,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { adminStatus: $scope.makeAdmin, // versionManagement: $scope.versionManagement, + managePlugins: $scope.managePlugins, // User Management @@ -1456,6 +1462,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { // $scope.versionManagement = true; + $scope.managePlugins = true; // User Management @@ -1527,6 +1534,7 @@ app.controller('modifyACLCtrl', function ($scope, $http) { // $scope.versionManagement = false; + $scope.managePlugins = false; // User Management @@ -1979,7 +1987,11 @@ app.controller('listTableUsers', function ($scope, $http) { var UserToDelete; - $scope.populateCurrentRecords = function () { + /** + * Reload the user table from the server. + * @param {boolean} [suppressSuccessNotify=false] - When true, no success toast (avoids double popups after delete/edit/suspend when the caller already notified). + */ + $scope.populateCurrentRecords = function (suppressSuccessNotify) { $scope.cyberpanelLoading = false; url = "/users/fetchTableUsers"; @@ -2003,11 +2015,13 @@ app.controller('listTableUsers', function ($scope, $http) { $scope.records = JSON.parse(response.data.data); - safePNotify({ - title: 'Success!', - text: 'Users successfully fetched!', - type: 'success' - }); + if (!suppressSuccessNotify) { + safePNotify({ + title: 'Success!', + text: 'Users successfully fetched!', + type: 'success' + }); + } } else { safePNotify({ @@ -2029,7 +2043,8 @@ app.controller('listTableUsers', function ($scope, $http) { } }; - $scope.populateCurrentRecords(); + /* Initial load: silent (no "fetched" toast on every page open) */ + $scope.populateCurrentRecords(true); $scope.deleteUserInitial = function (name){ @@ -2059,7 +2074,7 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { $scope.cyberpanelLoading = true; if (response.data.deleteStatus === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('deleteModal'); safePNotify({ title: 'Success!', @@ -2123,7 +2138,7 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('editModal'); safePNotify({ title: 'Success!', @@ -2177,7 +2192,7 @@ app.controller('listTableUsers', function ($scope, $http) { $scope.cyberpanelLoading = true; if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); hideModalById('editModal'); safePNotify({ title: 'Success!', @@ -2231,7 +2246,7 @@ app.controller('listTableUsers', function ($scope, $http) { function ListInitialDatas(response) { $scope.cyberpanelLoading = true; if (response.data.status === 1) { - $scope.populateCurrentRecords(); + $scope.populateCurrentRecords(true); safePNotify({ title: 'Success!', text: 'Action successfully started.', diff --git a/userManagment/templates/userManagment/createACL.html b/userManagment/templates/userManagment/createACL.html index 1a2da0760..49d03a0b0 100644 --- a/userManagment/templates/userManagment/createACL.html +++ b/userManagment/templates/userManagment/createACL.html @@ -162,6 +162,9 @@
    +
    + +
    {% trans "User Management" %}
    diff --git a/userManagment/templates/userManagment/createUser.html b/userManagment/templates/userManagment/createUser.html index 24e85da2d..b8332c9af 100644 --- a/userManagment/templates/userManagment/createUser.html +++ b/userManagment/templates/userManagment/createUser.html @@ -283,9 +283,11 @@
    - {% for items in aclNames %} - + {% endfor %}

    {% trans "Select the permission set for this user" %}

    diff --git a/userManagment/templates/userManagment/modifyACL.html b/userManagment/templates/userManagment/modifyACL.html index 41504c84f..e26536d7e 100644 --- a/userManagment/templates/userManagment/modifyACL.html +++ b/userManagment/templates/userManagment/modifyACL.html @@ -374,6 +374,10 @@
    +
    + + +
    diff --git a/userManagment/tests.py b/userManagment/tests.py index 78732947a..2e93ac1e4 100644 --- a/userManagment/tests.py +++ b/userManagment/tests.py @@ -124,6 +124,7 @@ class TestUserManagement(TestCase): data_ret = {'aclName': 'hello', 'makeAdmin':1, 'createNewUser': 1, 'versionManagement': 1, + 'managePlugins': 0, 'listUsers': 1, 'resellerCenter': 1, 'deleteUser': 1, @@ -190,6 +191,7 @@ class TestUserManagement(TestCase): 'adminStatus':1, 'createNewUser': 1, 'versionManagement': 1, + 'managePlugins': 0, 'listUsers': 1, 'resellerCenter': 1, 'deleteUser': 1, diff --git a/userManagment/views.py b/userManagment/views.py index 763f9a67d..2f3406fe5 100644 --- a/userManagment/views.py +++ b/userManagment/views.py @@ -4,6 +4,8 @@ from django.shortcuts import render, redirect from django.http import HttpResponse from django.db import models +from django.db.utils import IntegrityError, ProgrammingError, OperationalError +from django.views.decorators.csrf import ensure_csrf_cookie from loginSystem.views import loadLoginPage from loginSystem.models import Administrator, ACL import json @@ -50,24 +52,31 @@ def viewProfile(request): return proc.render() +@ensure_csrf_cookie def createUser(request): userID = request.session['userID'] currentACL = ACLManager.loadedACL(userID) if currentACL['admin'] == 1: aclNames = ACLManager.unFileteredACLs() + default_acl = aclNames[0] if aclNames else 'user' proc = httpProc(request, 'userManagment/createUser.html', - {'aclNames': aclNames, 'securityLevels': SecurityLevel.list()}) + {'aclNames': aclNames, 'default_acl_name': default_acl, + 'securityLevels': SecurityLevel.list()}) return proc.render() elif currentACL['changeUserACL'] == 1: aclNames = ACLManager.unFileteredACLs() + default_acl = aclNames[0] if aclNames else 'user' proc = httpProc(request, 'userManagment/createUser.html', - {'aclNames': aclNames, 'securityLevels': SecurityLevel.list()}) + {'aclNames': aclNames, 'default_acl_name': default_acl, + 'securityLevels': SecurityLevel.list()}) return proc.render() elif currentACL['createNewUser'] == 1: aclNames = ['user'] + default_acl = 'user' proc = httpProc(request, 'userManagment/createUser.html', - {'aclNames': aclNames, 'securityLevels': SecurityLevel.list()}) + {'aclNames': aclNames, 'default_acl_name': default_acl, + 'securityLevels': SecurityLevel.list()}) return proc.render() else: return ACLManager.loadError() @@ -213,8 +222,32 @@ def submitUserCreation(request): email = data['email'] userName = data['userName'] password = data['password'] - websitesLimit = data['websitesLimit'] - selectedACL = data['selectedACL'] + if userName is None: + userName = '' + else: + userName = str(userName).strip() + if not userName: + data_ret = {'status': 0, 'createStatus': 0, + 'error_message': 'Username is required.'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data, content_type='application/json') + if Administrator.objects.filter(userName=userName).exists(): + data_ret = {'status': 0, 'createStatus': 0, + 'error_message': 'That username is already in use. Choose a different username.'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data, content_type='application/json') + try: + websitesLimit = int(data['websitesLimit']) + except (KeyError, TypeError, ValueError): + websitesLimit = 0 + selectedACL = data.get('selectedACL') + if selectedACL is None or (isinstance(selectedACL, str) and not selectedACL.strip()): + data_ret = {'status': 0, 'createStatus': 0, + 'error_message': 'Please select an access control list (ACL).'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data, content_type='application/json') + if isinstance(selectedACL, str): + selectedACL = selectedACL.strip() selectedHomeDirectory = data.get('selectedHomeDirectory', '') if ACLManager.CheckRegEx("^[\w'\-,.][^0-9_!¡?÷?¿/\\+=@#$%ˆ&*(){}|~<>;:[\]]{2,}$", firstName) == 0: @@ -239,7 +272,13 @@ def submitUserCreation(request): except: securityLevel = 'HIGH' - selectedACL = ACL.objects.get(name=selectedACL) + try: + selectedACL = ACL.objects.get(name=selectedACL) + except ACL.DoesNotExist: + data_ret = {'status': 0, 'createStatus': 0, + 'error_message': 'The selected access level (ACL) was not found. Refresh the page and try again.'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data, content_type='application/json') if selectedACL.adminStatus == 1: type = 1 @@ -325,34 +364,43 @@ def submitUserCreation(request): # Handle home directory assignment from .homeDirectoryManager import HomeDirectoryManager from .models import HomeDirectory, UserHomeMapping - - if selectedHomeDirectory: - # Use selected home directory + + # Always resolve home_dir after home_path. Previously, if selectedHomeDirectory was set but + # the row was missing (DoesNotExist), we set home_path but left home_dir undefined and + # UserHomeMapping.objects.create() raised UnboundLocalError → "Failed to create user account". + home_dir = None + if selectedHomeDirectory not in (None, '', 0, '0', 'false', 'null'): try: - home_dir = HomeDirectory.objects.get(id=selectedHomeDirectory) - home_path = home_dir.path - except HomeDirectory.DoesNotExist: + hid = int(str(selectedHomeDirectory).strip()) + if hid > 0: + home_dir = HomeDirectory.objects.get(id=hid) + home_path = home_dir.path + else: + home_path = HomeDirectoryManager.getBestHomeDirectory() + except (ValueError, TypeError, HomeDirectory.DoesNotExist): home_path = HomeDirectoryManager.getBestHomeDirectory() else: - # Auto-select best home directory home_path = HomeDirectoryManager.getBestHomeDirectory() + + if home_dir is None: try: home_dir = HomeDirectory.objects.get(path=home_path) except HomeDirectory.DoesNotExist: - # Create home directory entry if it doesn't exist home_dir = HomeDirectory.objects.create( - name=home_path.split('/')[-1], + name=(home_path.split('/')[-1] or 'home'), path=home_path, is_active=True, is_default=(home_path == '/home') ) - + except HomeDirectory.MultipleObjectsReturned: + home_dir = HomeDirectory.objects.filter(path=home_path).order_by('id').first() + # Create user directory if HomeDirectoryManager.createUserDirectory(userName, home_path): - # Create user-home mapping - UserHomeMapping.objects.create( + # Create user-home mapping (ignore duplicate if re-run) + UserHomeMapping.objects.get_or_create( user=newAdmin, - home_directory=home_dir + defaults={'home_directory': home_dir} ) else: # Log error but don't fail user creation @@ -364,11 +412,23 @@ def submitUserCreation(request): final_json = json.dumps(data_ret) return HttpResponse(final_json, content_type='application/json') + except IntegrityError as e: + secure_log_error(e, 'submitUserCreation', request.session.get('userID', 'Unknown')) + data_ret = { + 'status': 0, + 'createStatus': 0, + 'error_message': 'That username is already in use. Choose a different username.', + } + json_data = json.dumps(data_ret) + return HttpResponse(json_data, content_type='application/json') + except Exception as e: secure_log_error(e, 'submitUserCreation', request.session.get('userID', 'Unknown')) data_ret = secure_error_response(e, 'Failed to create user account') + if isinstance(data_ret, dict): + data_ret['createStatus'] = 0 json_data = json.dumps(data_ret) - return HttpResponse(json_data) + return HttpResponse(json_data, content_type='application/json') except KeyError: data_ret = {'status': 0, 'createStatus': 0, 'error_message': "Not logged in as admin", } @@ -570,6 +630,62 @@ def deleteUser(request): return ACLManager.loadError() +def robust_delete_administrator(admin_instance): + """ + Delete an Administrator when optional WebAuthn tables were never migrated. + ORM .delete() still collects WebAuthnCredential etc. and MySQL raises 1146 if + webauthn_credentials is missing. Fall back to SQL DELETE on the admin row + (and any WebAuthn tables that do exist) after removing child admins (owner FK is integer, not DB FK). + """ + from django.db import connection + + if admin_instance is None: + return + + pk = admin_instance.pk + db_table = Administrator._meta.db_table + + try: + table_names = set(connection.introspection.table_names()) + except Exception: + table_names = set() + + webauthn_tables = ( + ('webauthn_credentials', 'user_id'), + ('webauthn_challenges', 'user_id'), + ('webauthn_sessions', 'user_id'), + ('webauthn_settings', 'user_id'), + ) + + for child in Administrator.objects.filter(owner=pk): + robust_delete_administrator(child) + + use_orm = 'webauthn_credentials' in table_names + if use_orm: + try: + admin_instance.delete() + return + except (ProgrammingError, OperationalError) as exc: + err = str(exc).lower() + if 'webauthn' not in err and "doesn't exist" not in err and 'does not exist' not in err: + raise + + qn = connection.ops.quote_name + tbl = qn(db_table) + col_id = qn('id') + with connection.cursor() as cursor: + for tname, cname in webauthn_tables: + if tname in table_names: + try: + cursor.execute( + 'DELETE FROM %s WHERE %s = %%s' % (qn(tname), qn(cname)), + [pk], + ) + except (ProgrammingError, OperationalError): + pass + cursor.execute('DELETE FROM %s WHERE %s = %%s' % (tbl, col_id), [pk]) + + def submitUserDeletion(request): try: @@ -610,31 +726,28 @@ def submitUserDeletion(request): user = Administrator.objects.get(userName=accountUsername) - childUsers = Administrator.objects.filter(owner=user.pk) - - for items in childUsers: - items.delete() - - user.delete() + robust_delete_administrator(user) data_ret = {'status': 1, 'deleteStatus': 1, 'error_message': 'None'} json_data = json.dumps(data_ret) - return HttpResponse(json_data) + return HttpResponse(json_data, content_type='application/json') else: data_ret = {'status': 0, 'deleteStatus': 0, 'error_message': 'Not enough privileges.'} json_data = json.dumps(data_ret) - return HttpResponse(json_data) + return HttpResponse(json_data, content_type='application/json') except Exception as e: secure_log_error(e, 'submitUserDeletion', request.session.get('userID', 'Unknown')) data_ret = secure_error_response(e, 'Failed to delete user account') + if isinstance(data_ret, dict): + data_ret['deleteStatus'] = 0 json_data = json.dumps(data_ret) - return HttpResponse(json_data) + return HttpResponse(json_data, content_type='application/json') except KeyError: data_ret = {'deleteStatus': 0, 'error_message': "Not logged in as admin", } json_data = json.dumps(data_ret) - return HttpResponse(json_data) + return HttpResponse(json_data, content_type='application/json') def createNewACL(request): @@ -1089,7 +1202,8 @@ def userMigration(request): return proc.render() except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f"Error loading user migration: {str(e)}") + from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as _cp_log + _cp_log.writeToFile(f"Error loading user migration: {str(e)}") return ACLManager.loadError()