From 1c43b34ffba51aabddce9f412eb292233763f5e4 Mon Sep 17 00:00:00 2001 From: Dvir Fitoussi Date: Tue, 10 Mar 2026 19:36:24 +0200 Subject: [PATCH 1/6] Store settings in config/ and add import/export Move application settings into a dedicated config/ directory and unify email settings into the single app_settings.json file. Update simple_org_chart.config to expose CONFIG_DIR/REPO_DIR and ensure directories are created. Modify email_config and settings save/load logic to read/write the unified SETTINGS_FILE while preserving unrelated keys (so email/settings don't overwrite each other). Add API endpoints (/api/settings/export and /api/settings/import) and corresponding UI (configure page, JS, and locale entries) to export/import configuration. Change user-scanner behavior to use GitHub releases (with PyPI fallback) and add UI-driven manual install flow instead of auto-install. Update Dockerfiles and compose files to mount ./config, add gosu and an entrypoint script to fix bind-mount permissions before dropping privileges. Update tests to expect the new config location and adjust other frontend and backend code to handle missing DOM elements and defaults. Overall this enables persistent, portable settings and safer container bind-mount handling. --- Dockerfile | 12 ++- deploy/entrypoint.sh | 12 +++ docker-compose-dev.yml | 16 +-- docker-compose.yml | 16 +-- simple_org_chart/app_main.py | 74 ++++++++++++++ simple_org_chart/config.py | 10 +- simple_org_chart/email_config.py | 53 +++++----- simple_org_chart/settings.py | 13 ++- simple_org_chart/user_scanner_service.py | 26 ++++- static/configure.js | 118 ++++++++++++++++++++--- static/locales/en-US.json | 30 +++++- static/reports.js | 25 ++--- templates/configure.html | 37 ++++++- tests/test_config.py | 10 +- tests/test_email_schedule.py | 13 +-- 15 files changed, 362 insertions(+), 103 deletions(-) create mode 100644 deploy/entrypoint.sh diff --git a/Dockerfile b/Dockerfile index e5f7747..e9a54aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ RUN apt-get update && apt-get install -y \ python3-pip \ gcc \ curl \ + gosu \ && rm -rf /var/lib/apt/lists/* \ && groupadd --system app \ && useradd --system --gid app --create-home app @@ -43,9 +44,14 @@ RUN playwright install --with-deps chromium && \ COPY . . # Create necessary directories for data persistence and adjust ownership -RUN mkdir -p static data data/photos repositories && \ +RUN mkdir -p static data data/photos config repositories && \ + chmod 777 data config repositories && \ chown -R app:app /app +# Make entrypoint executable +COPY deploy/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + # Expose port EXPOSE ${APP_PORT} @@ -53,8 +59,8 @@ EXPOSE ${APP_PORT} HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ CMD curl -f http://localhost:${APP_PORT:-5000}/ || exit 1 -# Switch to non-root user -USER app +# Entrypoint fixes bind-mount permissions then drops to 'app' user +ENTRYPOINT ["/entrypoint.sh"] # Run the application using gunicorn CMD ["gunicorn", "--config", "deploy/gunicorn.conf.py", "simple_org_chart:app"] diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..db55487 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Fix ownership of bind-mounted directories so the 'app' user can write to them +for dir in /app/data /app/config /app/repositories; do + if [ -d "$dir" ]; then + chown -R app:app "$dir" 2>/dev/null || true + fi +done + +# Drop privileges and exec the main process +exec gosu app "$@" diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index e594f7e..150ba2f 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -10,16 +10,6 @@ services: env_file: - .env volumes: - - orgchart_data_dev:/app/data - - orgchart_repos_dev:/app/repositories - labels: - com.docker.compose.project: "docker" - networks: - - orgchart_dev_default - -networks: - orgchart_dev_default: - -volumes: - orgchart_data_dev: - orgchart_repos_dev: + - ./data:/app/data + - ./config:/app/config + - ./repositories:/app/repositories diff --git a/docker-compose.yml b/docker-compose.yml index 61adcce..6b238df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,16 +10,6 @@ services: env_file: - .env volumes: - - orgchart_data:/app/data - - orgchart_repos:/app/repositories - labels: - com.docker.compose.project: "docker" - networks: - - default - -networks: - default: - -volumes: - orgchart_data: - orgchart_repos: + - ./data:/app/data + - ./config:/app/config + - ./repositories:/app/repositories diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py index 194201a..7ad94be 100644 --- a/simple_org_chart/app_main.py +++ b/simple_org_chart/app_main.py @@ -60,6 +60,7 @@ def flock(fd, op): from simple_org_chart.config import EMPLOYEE_LIST_FILE from simple_org_chart.auth import login_required, require_auth, sanitize_next_path from simple_org_chart.email_config import ( + DEFAULT_EMAIL_CONFIG, get_smtp_config, is_smtp_configured, load_email_config, @@ -1128,6 +1129,7 @@ def reset_all_settings(): os.remove(logo_path) save_settings(DEFAULT_SETTINGS) + save_email_config(DEFAULT_EMAIL_CONFIG) threading.Thread(target=restart_scheduler).start() @@ -1137,6 +1139,78 @@ def reset_all_settings(): return jsonify({'error': 'Reset failed'}), 500 +# --------------------------------------------------------------------------- +# Config export / import +# --------------------------------------------------------------------------- + +@app.route('/api/settings/export', methods=['GET']) +@require_auth +def export_settings(): + """Download all configuration as a single flat JSON file.""" + settings = load_settings() + email_config = load_email_config() + # Merge everything flat; strip transient runtime fields + merged = {} + merged.update(settings) + merged.update({k: v for k, v in email_config.items() if k != 'lastSent'}) + payload = json.dumps(merged, indent=2) + return app.response_class( + payload, + mimetype='application/json', + headers={ + 'Content-Disposition': 'attachment; filename="simple-org-chart-config.json"', + }, + ) + + +@app.route('/api/settings/import', methods=['POST']) +@require_auth +def import_settings(): + """Import configuration from an uploaded JSON file.""" + uploaded = request.files.get('file') + if not uploaded: + return jsonify({'error': 'No file uploaded'}), 400 + + try: + raw = uploaded.read() + incoming = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + logger.warning("Config import: invalid JSON – %s", exc) + return jsonify({'error': 'Invalid JSON file'}), 400 + + if not isinstance(incoming, dict): + return jsonify({'error': 'Expected a JSON object'}), 400 + + # Split incoming keys into settings vs email config + email_keys = set(DEFAULT_EMAIL_CONFIG.keys()) - {'lastSent'} + settings_data = {} + email_data = {} + + for key, value in incoming.items(): + if key in DEFAULT_SETTINGS: + settings_data[key] = value + elif key in email_keys: + email_data[key] = value + + settings_ok = save_settings(settings_data) + + email_ok = True + if email_data: + email_ok = save_email_config(email_data) + + if settings_ok and email_ok: + if 'updateTime' in settings_data or 'autoUpdateEnabled' in settings_data: + threading.Thread(target=restart_scheduler, daemon=True).start() + return jsonify({'success': True, 'settings': load_settings()}) + + errors = [] + if not settings_ok: + errors.append('settings') + if not email_ok: + errors.append('email config') + return jsonify({'error': f'Failed to save: {", ".join(errors)}'}), 500 + + @app.route('/api/email-config', methods=['GET', 'POST']) @require_auth @limiter.limit(RATE_LIMIT_SETTINGS) diff --git a/simple_org_chart/config.py b/simple_org_chart/config.py index 3926275..dd295bd 100644 --- a/simple_org_chart/config.py +++ b/simple_org_chart/config.py @@ -11,10 +11,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" +CONFIG_DIR = BASE_DIR / "config" STATIC_DIR = BASE_DIR / "static" TEMPLATE_DIR = BASE_DIR / "templates" -SETTINGS_FILE = DATA_DIR / "app_settings.json" +REPO_DIR = BASE_DIR / "repositories" +SETTINGS_FILE = CONFIG_DIR / "app_settings.json" DATA_FILE = DATA_DIR / "employee_data.json" MISSING_MANAGER_FILE = DATA_DIR / "missing_manager_records.json" EMPLOYEE_LIST_FILE = DATA_DIR / "employee_list.json" @@ -28,8 +30,8 @@ def ensure_directories() -> None: - """Ensure that the application's data and static directories exist.""" - for target in (DATA_DIR, STATIC_DIR): + """Ensure that the application's data, config, static, and repo directories exist.""" + for target in (DATA_DIR, CONFIG_DIR, STATIC_DIR, REPO_DIR): try: target.mkdir(parents=True, exist_ok=True) except OSError as error: @@ -44,8 +46,10 @@ def as_posix_env(mapping: Dict[str, Path]) -> Dict[str, str]: __all__ = [ "BASE_DIR", "DATA_DIR", + "CONFIG_DIR", "STATIC_DIR", "TEMPLATE_DIR", + "REPO_DIR", "SETTINGS_FILE", "DATA_FILE", "MISSING_MANAGER_FILE", diff --git a/simple_org_chart/email_config.py b/simple_org_chart/email_config.py index 4d3b149..1554912 100644 --- a/simple_org_chart/email_config.py +++ b/simple_org_chart/email_config.py @@ -9,12 +9,10 @@ from pathlib import Path from typing import Any, Dict, List -from .config import DATA_DIR +from .config import SETTINGS_FILE logger = logging.getLogger(__name__) -EMAIL_CONFIG_FILE = DATA_DIR / "email_config.json" - # Default email configuration DEFAULT_EMAIL_CONFIG: Dict[str, Any] = { "enabled": False, @@ -27,6 +25,9 @@ "lastSent": None, # ISO timestamp of last sent email } +# Keys that belong to the email configuration +_EMAIL_KEYS = set(DEFAULT_EMAIL_CONFIG.keys()) + def get_smtp_config() -> Dict[str, Any]: """Load SMTP configuration from environment variables.""" # Get encryption setting (TLS, SSL, or None) @@ -52,14 +53,15 @@ def is_smtp_configured() -> bool: def load_email_config() -> Dict[str, Any]: - """Load email configuration from disk or return defaults.""" - if EMAIL_CONFIG_FILE.exists(): + """Load email configuration from app_settings.json.""" + if SETTINGS_FILE.exists(): try: - with EMAIL_CONFIG_FILE.open("r", encoding="utf-8") as handle: + with SETTINGS_FILE.open("r", encoding="utf-8") as handle: stored = json.load(handle) - # Merge with defaults to ensure all fields exist merged = DEFAULT_EMAIL_CONFIG.copy() - merged.update(stored) + for key in _EMAIL_KEYS: + if key in stored: + merged[key] = stored[key] return merged except Exception as error: logger.error("Error loading email config: %s", error) @@ -68,13 +70,18 @@ def load_email_config() -> Dict[str, Any]: def save_email_config(config: Dict[str, Any]) -> bool: - """Save email configuration to disk.""" - EMAIL_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - - # Preserve lastSent from existing on-disk config when the caller - # (e.g. the configure page) does not supply it. - existing = load_email_config() - + """Save email configuration into app_settings.json.""" + SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Read the current file so we keep all other keys intact + existing: Dict[str, Any] = {} + if SETTINGS_FILE.exists(): + try: + with SETTINGS_FILE.open("r", encoding="utf-8") as handle: + existing = json.load(handle) + except Exception: + pass + # Merge with defaults persisted = DEFAULT_EMAIL_CONFIG.copy() persisted.update(config) @@ -102,14 +109,17 @@ def save_email_config(config: Dict[str, Any]) -> bool: if persisted.get("dayOfMonth") not in ("first", "last"): persisted["dayOfMonth"] = "first" - logger.info("Saving email configuration to: %s", EMAIL_CONFIG_FILE) + # Write email keys back into the shared config file + existing.update(persisted) + + logger.info("Saving email configuration to: %s", SETTINGS_FILE) try: - with EMAIL_CONFIG_FILE.open("w", encoding="utf-8") as handle: - json.dump(persisted, handle, indent=2) + with SETTINGS_FILE.open("w", encoding="utf-8") as handle: + json.dump(existing, handle, indent=2) logger.info("Email configuration saved successfully") return True except Exception as error: - logger.error("Error saving email config to %s: %s", EMAIL_CONFIG_FILE, error) + logger.error("Error saving email config to %s: %s", SETTINGS_FILE, error) return False @@ -178,8 +188,8 @@ def should_send_email_now() -> bool: The check is **calendar-based**: it first verifies that today matches the configured schedule day, then verifies that no email has already been sent for the current period. The ``lastSent`` timestamp is - persisted in ``email_config.json`` so the decision survives restarts - and container rebuilds as long as the ``data/`` directory is retained. + persisted in ``app_settings.json`` so the decision survives restarts + and container rebuilds as long as the ``config/`` directory is retained. Returns: True if email should be sent, False otherwise @@ -228,7 +238,6 @@ def mark_email_sent() -> None: __all__ = [ "DEFAULT_EMAIL_CONFIG", - "EMAIL_CONFIG_FILE", "get_smtp_config", "is_smtp_configured", "load_email_config", diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py index c187e66..e19146e 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -151,6 +151,15 @@ def save_settings(settings: Dict[str, Any]) -> bool: """Persist settings to disk, returning True on success.""" SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + # Read the current file so we keep all other keys intact + existing: Dict[str, Any] = {} + if SETTINGS_FILE.exists(): + try: + with SETTINGS_FILE.open("r", encoding="utf-8") as handle: + existing = json.load(handle) + except Exception: + pass + # Update stored defaults with provided overrides persisted = DEFAULT_SETTINGS.copy() persisted.update(settings) @@ -171,10 +180,12 @@ def save_settings(settings: Dict[str, Any]) -> bool: for level, color in default_node_colors.items() } + existing.update(persisted) + logger.info("Attempting to save settings to: %s", SETTINGS_FILE) try: with SETTINGS_FILE.open("w", encoding="utf-8") as handle: - json.dump(persisted, handle, indent=2) + json.dump(existing, handle, indent=2) except Exception as error: # noqa: BLE001 - mirror legacy behaviour logger.error("Error saving settings to %s: %s", SETTINGS_FILE, error) return False diff --git a/simple_org_chart/user_scanner_service.py b/simple_org_chart/user_scanner_service.py index 2a1928c..f132d0d 100644 --- a/simple_org_chart/user_scanner_service.py +++ b/simple_org_chart/user_scanner_service.py @@ -36,12 +36,14 @@ # rate-limits (HTTP 429) on third-party services. _SCAN_DELAY_SECONDS = 1.5 -REPO_DIR = app_config.BASE_DIR / "repositories" / "user-scanner" +REPO_DIR = app_config.REPO_DIR / "user-scanner" USER_SCANNER_CACHE = app_config.DATA_DIR / "user_scanner_results.json" USER_SCANNER_HISTORY = app_config.DATA_DIR / "user_scanner_history.json" USER_SCANNER_XLSX_DIR = app_config.DATA_DIR / "user_scanner_exports" MAX_SCAN_HISTORY = 5 PYPI_PACKAGE_NAME = "user-scanner" +GITHUB_REPO_OWNER = "kaifcodec" +GITHUB_REPO_NAME = "user-scanner" # --------------------------------------------------------------------------- @@ -117,7 +119,27 @@ def _ensure_on_path() -> None: def get_latest_pypi_version() -> Optional[str]: - """Query PyPI for the latest release version of user-scanner.""" + """Query GitHub releases for the latest version of user-scanner. + + Falls back to PyPI if the GitHub API call fails. + """ + # Primary: GitHub Releases API (matches real releases) + try: + resp = requests.get( + f"https://api.github.com/repos/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/releases/latest", + headers={"Accept": "application/vnd.github+json"}, + timeout=10, + ) + if resp.ok: + tag = resp.json().get("tag_name", "") + # Strip leading 'v' or 'Version=>' prefixes + version = re.sub(r"^(v|Version=>)", "", tag).strip() + if version: + return version + except Exception as exc: + logger.warning("Failed to check GitHub for user-scanner updates: %s", exc) + + # Fallback: PyPI try: resp = requests.get( f"https://pypi.org/pypi/{PYPI_PACKAGE_NAME}/json", diff --git a/static/configure.js b/static/configure.js index 2dabfc7..dfe8c03 100644 --- a/static/configure.js +++ b/static/configure.js @@ -1637,6 +1637,7 @@ async function logout() { } async function saveAllSettings() { + try { const logoResetRequested = pendingLogoReset; const faviconResetRequested = pendingFaviconReset; @@ -1650,7 +1651,7 @@ async function saveAllSettings() { }; const settings = { - chartTitle: document.getElementById('chartTitle').value || 'Organization Chart', + chartTitle: document.getElementById('chartTitle')?.value || 'Organization Chart', headerColor: resolveColorValue('headerColor', 'headerColorHex', DEFAULT_HEADER_COLOR), nodeColors: Object.keys(NODE_COLOR_DEFAULTS).reduce((accumulator, level) => { accumulator[level] = resolveColorValue( @@ -1660,21 +1661,21 @@ async function saveAllSettings() { ); return accumulator; }, {}), - autoUpdateEnabled: document.getElementById('autoUpdateEnabled').checked, - updateTime: localTimeToUtc(document.getElementById('updateTime').value), - collapseLevel: document.getElementById('collapseLevel').value, - searchAutoExpand: document.getElementById('searchAutoExpand').checked, - searchHighlight: document.getElementById('searchHighlight').checked, - searchHighlightDuration: parseInt(document.getElementById('searchHighlightDuration').value, 10), - newEmployeeMonths: parseInt(document.getElementById('newEmployeeMonths').value, 10), - hideDisabledUsers: document.getElementById('hideDisabledUsers').checked, - hideGuestUsers: document.getElementById('hideGuestUsers').checked, - hideNoTitle: document.getElementById('hideNoTitle').checked, + autoUpdateEnabled: document.getElementById('autoUpdateEnabled')?.checked ?? true, + updateTime: localTimeToUtc(document.getElementById('updateTime')?.value || '20:00'), + collapseLevel: document.getElementById('collapseLevel')?.value || '2', + searchAutoExpand: document.getElementById('searchAutoExpand')?.checked ?? true, + searchHighlight: document.getElementById('searchHighlight')?.checked ?? true, + searchHighlightDuration: parseInt(document.getElementById('searchHighlightDuration')?.value || '10', 10), + newEmployeeMonths: parseInt(document.getElementById('newEmployeeMonths')?.value || '3', 10), + hideDisabledUsers: document.getElementById('hideDisabledUsers')?.checked ?? true, + hideGuestUsers: document.getElementById('hideGuestUsers')?.checked ?? true, + hideNoTitle: document.getElementById('hideNoTitle')?.checked ?? true, ignoredDepartments: getIgnoredDepartmentsValue(), ignoredTitles: getIgnoredTitlesValue(), - ignoredEmployees: getIgnoredEmployeesValue(), - printOrientation: document.getElementById('printOrientation').value, - printSize: document.getElementById('printSize').value, + ignoredEmployees: getIgnoredEmployeesValue(), + printOrientation: document.getElementById('printOrientation')?.value || 'landscape', + printSize: document.getElementById('printSize')?.value || 'a4', multiLineChildrenThreshold: parseInt(document.getElementById('multiLineChildrenThreshold')?.value || '20', 10), topLevelUserEmail: document.getElementById('topLevelUserInput')?.value || '', topLevelUserId: document.getElementById('topLevelUserIdInput')?.value || '', @@ -1683,7 +1684,6 @@ async function saveAllSettings() { teamsPresenceEnabled: document.getElementById('teamsPresenceEnabled')?.checked || false }; - try { if (logoResetRequested) { const response = await fetch(`${API_BASE_URL}/api/reset-logo`, { method: 'POST' }); if (!response.ok) { @@ -1837,6 +1837,7 @@ async function triggerUpdate() { function showStatus(message, type) { const statusEl = document.getElementById('statusMessage'); + if (!statusEl) return; statusEl.textContent = message; statusEl.className = `status-message ${type}`; setTimeout(() => { @@ -1844,6 +1845,54 @@ function showStatus(message, type) { }, 3000); } +/* ---------- config export / import ---------- */ + +function exportConfig() { + window.location.href = `${API_BASE_URL}/api/settings/export`; +} + +function importConfig() { + const fileInput = document.getElementById('configFileInput'); + if (!fileInput) return; + fileInput.click(); +} + +function handleConfigFileSelected(event) { + const file = event.target.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + fetch(`${API_BASE_URL}/api/settings/import`, { + method: 'POST', + body: formData, + }) + .then(res => res.json().then(data => ({ ok: res.ok, data }))) + .then(({ ok, data }) => { + if (!ok) throw new Error(data.error || 'Import failed'); + showStatus( + getTranslation('configure.configTransfer.flash.importSuccess', 'Configuration imported successfully.'), + 'success' + ); + isInitializing = true; + loadSettings().then(() => { + isInitializing = false; + clearUnsavedChangeState(); + }); + }) + .catch(err => { + console.error('Config import failed', err); + showStatus( + getTranslation('configure.configTransfer.flash.importError', 'Failed to import configuration: ') + err.message, + 'error' + ); + }) + .finally(() => { + event.target.value = ''; + }); +} + function registerConfigActions() { const actionHandlers = { 'reset-chart-title': resetChartTitle, @@ -1860,6 +1909,8 @@ function registerConfigActions() { 'reset-multiline-settings': resetMultiLineSettings, 'reset-export-columns': resetExportColumns, 'trigger-update': triggerUpdate, + 'export-config': exportConfig, + 'import-config': importConfig, 'discard-unsaved': discardUnsavedChanges, 'save-all': saveAllSettings, 'reset-all': resetAllSettings, @@ -1930,6 +1981,9 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize email reports configuration await initEmailReportsConfig(); + + // Config import file listener + document.getElementById('configFileInput')?.addEventListener('change', handleConfigFileSelected); }); // ───────────────────────────────────────────────────────────────────────────── @@ -2259,6 +2313,9 @@ saveAllSettings = enhancedSaveAllSettings; async function _initUserScannerConfigUI(isEnabled) { const statusEl = document.getElementById('userScannerStatus'); + const installRow = document.getElementById('userScannerInstallRow'); + const installBtn = document.getElementById('installUserScannerBtn'); + const installStatusEl = document.getElementById('userScannerInstallStatus'); const updateRow = document.getElementById('userScannerUpdateRow'); const checkBtn = document.getElementById('checkUserScannerUpdateBtn'); const applyBtn = document.getElementById('applyUserScannerUpdateBtn'); @@ -2268,6 +2325,7 @@ async function _initUserScannerConfigUI(isEnabled) { if (!isEnabled) { statusEl.textContent = 'User Scanner is disabled.'; + if (installRow) installRow.style.display = 'none'; if (updateRow) updateRow.style.display = 'none'; return; } @@ -2279,9 +2337,37 @@ async function _initUserScannerConfigUI(isEnabled) { const data = await resp.json(); if (!data.installed) { - statusEl.textContent = 'Not installed yet — it will be downloaded automatically on first use.'; + statusEl.textContent = 'Not installed — download to enable the scanner.'; + if (installRow) installRow.style.display = 'flex'; if (updateRow) updateRow.style.display = 'none'; + + // Wire up install button + if (installBtn) { + installBtn.onclick = async () => { + installBtn.disabled = true; + if (installStatusEl) installStatusEl.textContent = 'Downloading…'; + try { + const installResp = await fetch(`${window.location.origin}/api/user-scanner/install`, { + method: 'POST', + credentials: 'include', + }); + const result = await installResp.json(); + if (result.success) { + if (installStatusEl) installStatusEl.textContent = ''; + // Re-init to show installed state + _initUserScannerConfigUI(true); + } else { + if (installStatusEl) installStatusEl.textContent = result.error || 'Installation failed.'; + installBtn.disabled = false; + } + } catch (err) { + if (installStatusEl) installStatusEl.textContent = 'Download failed: ' + err.message; + installBtn.disabled = false; + } + }; + } } else { + if (installRow) installRow.style.display = 'none'; const ver = data.version || 'unknown'; statusEl.innerHTML = ''; const versionText = document.createElement('span'); diff --git a/static/locales/en-US.json b/static/locales/en-US.json index bef9aaf..c3d9d3e 100644 --- a/static/locales/en-US.json +++ b/static/locales/en-US.json @@ -204,8 +204,14 @@ "userScanner": { "enable": { "label": "User Scanner (OSINT)", - "description": "Enable the User Scanner tool to check email presence across multiple platforms. This uses an external open-source tool (user-scanner) which will be downloaded automatically from PyPI when enabled and kept up to date.", + "description": "Enable the User Scanner tool to check email presence across multiple platforms. This uses an external open-source tool (user-scanner) which must be downloaded from the config page before use.", "toggleLabel": "Enable User Scanner" + }, + "install": { + "button": "Download & Install", + "downloading": "Downloading…", + "success": "Installed successfully.", + "failed": "Installation failed." } }, "presence": { @@ -411,6 +417,28 @@ "statusFailed": "✗ Update failed" } }, + "configTransfer": { + "title": "📦 Configuration Transfer", + "export": { + "label": "Export Configuration", + "description": "Download all settings as a JSON file. Employee data is not included.", + "button": "Export" + }, + "import": { + "label": "Import Configuration", + "description": "Upload a previously exported JSON file to restore settings.", + "button": "Import" + }, + "reset": { + "label": "Reset to Defaults", + "description": "Restore all settings to their factory defaults. Employee data is not affected.", + "button": "Reset" + }, + "flash": { + "importSuccess": "Configuration imported successfully.", + "importError": "Failed to import configuration: " + } + }, "buttons": { "resetDefault": "Reset to Default", "resetAllNodeColors": "Reset All Node Colors", diff --git a/static/reports.js b/static/reports.js index 56e9839..540463b 100644 --- a/static/reports.js +++ b/static/reports.js @@ -1328,22 +1328,7 @@ async function checkUserScannerEnabled() { const resp = await fetch(`${API_BASE_URL}/api/user-scanner/status`, { credentials: 'include' }); if (resp.ok) { const data = await resp.json(); - // Auto-install when enabled but not yet installed - if (data.enabled && !data.installed) { - try { - const installResp = await fetch(`${API_BASE_URL}/api/user-scanner/install`, { - method: 'POST', - credentials: 'include', - }); - if (installResp.ok) { - const installData = await installResp.json(); - data.installed = installData.success; - data.version = installData.version || null; - } - } catch (installErr) { - console.error('Auto-install of user-scanner failed:', installErr); - } - } + // Only enable when both enabled in settings AND installed on disk userScannerEnabled = data.enabled && data.installed; return data; } @@ -2331,11 +2316,15 @@ async function initializeReportsPage() { const reportSelect = qs('reportTypeSelect'); if (reportSelect) { - // Check if user-scanner is enabled; hide option if not + // Check if user-scanner is enabled and installed; hide option if not const scannerStatus = await checkUserScannerEnabled(); const scannerOption = reportSelect.querySelector('option[value="user-scanner"]'); - if (scannerOption && !scannerStatus.enabled) { + if (scannerOption && !(scannerStatus.enabled && scannerStatus.installed)) { scannerOption.style.display = 'none'; + // If scanner was selected but unavailable, fall back to default + if (currentReportKey === 'user-scanner') { + currentReportKey = 'missing-manager'; + } } reportSelect.value = currentReportKey; diff --git a/templates/configure.html b/templates/configure.html index ecbd093..83fc9a5 100644 --- a/templates/configure.html +++ b/templates/configure.html @@ -337,7 +337,7 @@

-
Enable the User Scanner tool to check email presence across multiple platforms. This uses an external open-source tool (user-scanner) which will be downloaded automatically from PyPI when enabled and kept up to date.
+
Enable the User Scanner tool to check email presence across multiple platforms. This uses an external open-source tool (user-scanner) which must be downloaded from the config page before use.
+
+ +
+

📦 Configuration Transfer

+ +
+
+ +
Download all settings as a JSON file. Employee data is not included.
+
+ +
+
+
+ +
Upload a previously exported JSON file to restore settings.
+
+ + +
+
+
+ +
Restore all settings to their factory defaults. Employee data is not affected.
+
+ +
+
+
+
+
-
diff --git a/tests/test_config.py b/tests/test_config.py index 276c234..8cf03ce 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ from simple_org_chart.config import ( BASE_DIR, + CONFIG_DIR, DATA_DIR, STATIC_DIR, TEMPLATE_DIR, @@ -29,8 +30,8 @@ def test_static_dir_under_base(self): def test_template_dir_under_base(self): assert TEMPLATE_DIR.parent == BASE_DIR - def test_settings_file_in_data_dir(self): - assert SETTINGS_FILE.parent == DATA_DIR + def test_settings_file_in_config_dir(self): + assert SETTINGS_FILE.parent == CONFIG_DIR def test_data_file_in_data_dir(self): assert DATA_FILE.parent == DATA_DIR @@ -41,17 +42,20 @@ def test_employee_list_in_data_dir(self): class TestEnsureDirectories: def test_creates_directories(self, tmp_path: Path, monkeypatch): - """ensure_directories should create DATA_DIR and STATIC_DIR.""" + """ensure_directories should create DATA_DIR, CONFIG_DIR, and STATIC_DIR.""" import simple_org_chart.config as cfg fake_data = tmp_path / "data" + fake_config = tmp_path / "config" fake_static = tmp_path / "static" monkeypatch.setattr(cfg, "DATA_DIR", fake_data) + monkeypatch.setattr(cfg, "CONFIG_DIR", fake_config) monkeypatch.setattr(cfg, "STATIC_DIR", fake_static) ensure_directories() assert fake_data.exists(), "DATA_DIR was not created by ensure_directories()" + assert fake_config.exists(), "CONFIG_DIR was not created by ensure_directories()" assert fake_static.exists(), "STATIC_DIR was not created by ensure_directories()" diff --git a/tests/test_email_schedule.py b/tests/test_email_schedule.py index 33d06bc..598a815 100644 --- a/tests/test_email_schedule.py +++ b/tests/test_email_schedule.py @@ -23,6 +23,7 @@ save_email_config, should_send_email_now, ) +import simple_org_chart.email_config as _email_config_mod # --------------------------------------------------------------------------- @@ -249,8 +250,8 @@ class TestSavePreservesLastSent: """Ensure saving from the configure page doesn't wipe lastSent.""" def test_last_sent_preserved_on_save(self, tmp_path, monkeypatch): - config_file = tmp_path / "email_config.json" - monkeypatch.setattr("simple_org_chart.email_config.EMAIL_CONFIG_FILE", config_file) + config_file = tmp_path / "app_settings.json" + monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) # Simulate a previous email send initial = DEFAULT_EMAIL_CONFIG.copy() @@ -265,8 +266,8 @@ def test_last_sent_preserved_on_save(self, tmp_path, monkeypatch): assert reloaded["lastSent"] == "2026-03-01T12:00:00+00:00" def test_last_sent_overwritten_when_explicitly_provided(self, tmp_path, monkeypatch): - config_file = tmp_path / "email_config.json" - monkeypatch.setattr("simple_org_chart.email_config.EMAIL_CONFIG_FILE", config_file) + config_file = tmp_path / "app_settings.json" + monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) initial = DEFAULT_EMAIL_CONFIG.copy() initial["lastSent"] = "2026-03-01T12:00:00+00:00" @@ -279,8 +280,8 @@ def test_last_sent_overwritten_when_explicitly_provided(self, tmp_path, monkeypa assert reloaded["lastSent"] == "2026-04-01T08:00:00+00:00" def test_last_sent_none_when_no_prior_config(self, tmp_path, monkeypatch): - config_file = tmp_path / "email_config.json" - monkeypatch.setattr("simple_org_chart.email_config.EMAIL_CONFIG_FILE", config_file) + config_file = tmp_path / "app_settings.json" + monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) save_email_config({"enabled": True, "frequency": "weekly"}) From eef83aa8723c0024cfef8c4eec83b8deec9860dd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:54:49 +0200 Subject: [PATCH 2/6] Fix settings file safety, export filtering, container permissions, and i18n in install flow (#50) * Initial plan * Address all PR review comments: locking, i18n, security, naming, and permissions Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> --- Dockerfile | 2 +- deploy/entrypoint.sh | 12 +- simple_org_chart/app_main.py | 12 +- simple_org_chart/email_config.py | 66 +- simple_org_chart/settings.py | 48 +- simple_org_chart/user_scanner_service.py | 6 +- static/configure.js | 39 +- static/locales/en-US.json | 1462 +++++++++++----------- 8 files changed, 868 insertions(+), 779 deletions(-) diff --git a/Dockerfile b/Dockerfile index e9a54aa..00ed105 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,7 @@ COPY . . # Create necessary directories for data persistence and adjust ownership RUN mkdir -p static data data/photos config repositories && \ - chmod 777 data config repositories && \ + chmod 775 data config repositories && \ chown -R app:app /app # Make entrypoint executable diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index db55487..b709e67 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,10 +1,18 @@ #!/bin/bash set -e -# Fix ownership of bind-mounted directories so the 'app' user can write to them +APP_UID=$(id -u app) +APP_GID=$(id -g app) + +# Fix ownership of bind-mounted directories so the 'app' user can write to them. +# Only chown when the top-level directory owner doesn't already match to avoid +# slow recursive traversal of large bind mounts on every container start. for dir in /app/data /app/config /app/repositories; do if [ -d "$dir" ]; then - chown -R app:app "$dir" 2>/dev/null || true + current_owner=$(stat -c '%u:%g' "$dir" 2>/dev/null || echo "") + if [ -z "$current_owner" ] || [ "$current_owner" != "$APP_UID:$APP_GID" ]; then + chown -R app:app "$dir" 2>/dev/null || true + fi fi done diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py index 7ad94be..a54b0da 100644 --- a/simple_org_chart/app_main.py +++ b/simple_org_chart/app_main.py @@ -1149,9 +1149,10 @@ def export_settings(): """Download all configuration as a single flat JSON file.""" settings = load_settings() email_config = load_email_config() - # Merge everything flat; strip transient runtime fields + # Merge everything flat; strip transient runtime fields and non-default keys + settings_public = {k: v for k, v in settings.items() if k in DEFAULT_SETTINGS} merged = {} - merged.update(settings) + merged.update(settings_public) merged.update({k: v for k, v in email_config.items() if k != 'lastSent'}) payload = json.dumps(merged, indent=2) return app.response_class( @@ -1171,6 +1172,13 @@ def import_settings(): if not uploaded: return jsonify({'error': 'No file uploaded'}), 400 + # Enforce file size limit consistent with other upload endpoints + uploaded.seek(0, 2) + file_size = uploaded.tell() + uploaded.seek(0) + if file_size > MAX_FILE_SIZE: + return jsonify({'error': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024 * 1024)}MB'}), 413 + try: raw = uploaded.read() incoming = json.loads(raw) diff --git a/simple_org_chart/email_config.py b/simple_org_chart/email_config.py index 1554912..57ef6aa 100644 --- a/simple_org_chart/email_config.py +++ b/simple_org_chart/email_config.py @@ -2,14 +2,17 @@ from __future__ import annotations +import contextlib import json import logging import os +import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Dict, List from .config import SETTINGS_FILE +from .settings import _settings_file_lock logger = logging.getLogger(__name__) @@ -73,49 +76,68 @@ def save_email_config(config: Dict[str, Any]) -> bool: """Save email configuration into app_settings.json.""" SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) - # Read the current file so we keep all other keys intact - existing: Dict[str, Any] = {} - if SETTINGS_FILE.exists(): - try: - with SETTINGS_FILE.open("r", encoding="utf-8") as handle: - existing = json.load(handle) - except Exception: - pass - # Merge with defaults persisted = DEFAULT_EMAIL_CONFIG.copy() persisted.update(config) - + if 'lastSent' not in config: - persisted['lastSent'] = existing.get('lastSent') - + # lastSent will be preserved from the existing file inside the lock below + persisted.pop('lastSent', None) + # Validate file types valid_file_types = {"svg", "png", "pdf", "xlsx"} persisted["fileTypes"] = [ - ft for ft in persisted.get("fileTypes", []) + ft for ft in persisted.get("fileTypes", []) if ft in valid_file_types ] - + # Validate frequency if persisted.get("frequency") not in ("daily", "weekly", "monthly"): persisted["frequency"] = "weekly" - + # Validate day of week valid_days = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} if persisted.get("dayOfWeek", "").lower() not in valid_days: persisted["dayOfWeek"] = "monday" - + # Validate day of month if persisted.get("dayOfMonth") not in ("first", "last"): persisted["dayOfMonth"] = "first" - - # Write email keys back into the shared config file - existing.update(persisted) - + logger.info("Saving email configuration to: %s", SETTINGS_FILE) try: - with SETTINGS_FILE.open("w", encoding="utf-8") as handle: - json.dump(existing, handle, indent=2) + with _settings_file_lock: + # Read the current file so we keep all other keys intact + existing: Dict[str, Any] = {} + if SETTINGS_FILE.exists(): + try: + with SETTINGS_FILE.open("r", encoding="utf-8") as handle: + loaded = json.load(handle) + if isinstance(loaded, dict): + existing = loaded + except Exception: + pass + + # Preserve existing lastSent when caller didn't explicitly supply one + if 'lastSent' not in config: + persisted['lastSent'] = existing.get('lastSent') + + # Write email keys back into the shared config file + existing.update(persisted) + + # Atomic write: write to a temp file then replace + tmp_fd, tmp_path = tempfile.mkstemp( + dir=SETTINGS_FILE.parent, suffix=".tmp" + ) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as tmp_handle: + json.dump(existing, tmp_handle, indent=2) + os.replace(tmp_path, SETTINGS_FILE) + except Exception: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise + logger.info("Email configuration saved successfully") return True except Exception as error: diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py index e19146e..67763d1 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -2,14 +2,22 @@ from __future__ import annotations +import contextlib import json import logging import os import re +import tempfile +import threading from typing import Any, Dict, Iterable, Set from .config import SETTINGS_FILE +# Shared in-process lock protecting all reads/writes to SETTINGS_FILE. +# Both settings.py and email_config.py import this lock so that concurrent +# writes from different code paths (scheduler, HTTP handlers) are serialised. +_settings_file_lock = threading.Lock() + logger = logging.getLogger(__name__) DEFAULT_SETTINGS: Dict[str, Any] = { @@ -151,15 +159,6 @@ def save_settings(settings: Dict[str, Any]) -> bool: """Persist settings to disk, returning True on success.""" SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) - # Read the current file so we keep all other keys intact - existing: Dict[str, Any] = {} - if SETTINGS_FILE.exists(): - try: - with SETTINGS_FILE.open("r", encoding="utf-8") as handle: - existing = json.load(handle) - except Exception: - pass - # Update stored defaults with provided overrides persisted = DEFAULT_SETTINGS.copy() persisted.update(settings) @@ -180,12 +179,34 @@ def save_settings(settings: Dict[str, Any]) -> bool: for level, color in default_node_colors.items() } - existing.update(persisted) - logger.info("Attempting to save settings to: %s", SETTINGS_FILE) try: - with SETTINGS_FILE.open("w", encoding="utf-8") as handle: - json.dump(existing, handle, indent=2) + with _settings_file_lock: + # Read the current file so we keep all other keys intact + existing: Dict[str, Any] = {} + if SETTINGS_FILE.exists(): + try: + with SETTINGS_FILE.open("r", encoding="utf-8") as handle: + loaded = json.load(handle) + if isinstance(loaded, dict): + existing = loaded + except Exception: + pass + + existing.update(persisted) + + # Atomic write: write to a temp file then replace + tmp_fd, tmp_path = tempfile.mkstemp( + dir=SETTINGS_FILE.parent, suffix=".tmp" + ) + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as tmp_handle: + json.dump(existing, tmp_handle, indent=2) + os.replace(tmp_path, SETTINGS_FILE) + except Exception: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise except Exception as error: # noqa: BLE001 - mirror legacy behaviour logger.error("Error saving settings to %s: %s", SETTINGS_FILE, error) return False @@ -288,6 +309,7 @@ def employee_is_ignored(name: str | None, email: str | None, user_principal_name __all__ = [ "DEFAULT_SETTINGS", + "_settings_file_lock", "department_is_ignored", "employee_is_ignored", "load_settings", diff --git a/simple_org_chart/user_scanner_service.py b/simple_org_chart/user_scanner_service.py index f132d0d..362e24f 100644 --- a/simple_org_chart/user_scanner_service.py +++ b/simple_org_chart/user_scanner_service.py @@ -118,7 +118,7 @@ def _ensure_on_path() -> None: sys.path.insert(0, repo_str) -def get_latest_pypi_version() -> Optional[str]: +def get_latest_release_version() -> Optional[str]: """Query GitHub releases for the latest version of user-scanner. Falls back to PyPI if the GitHub API call fails. @@ -153,10 +153,10 @@ def get_latest_pypi_version() -> Optional[str]: def check_for_update() -> Dict[str, Any]: - """Compare installed version against PyPI and return status dict.""" + """Compare installed version against the latest release and return status dict.""" installed = is_installed() current = get_version() if installed else None - latest = get_latest_pypi_version() + latest = get_latest_release_version() update_available = False if current and latest: update_available = _version_tuple(latest) > _version_tuple(current) diff --git a/static/configure.js b/static/configure.js index dfe8c03..0c2172b 100644 --- a/static/configure.js +++ b/static/configure.js @@ -2337,7 +2337,7 @@ async function _initUserScannerConfigUI(isEnabled) { const data = await resp.json(); if (!data.installed) { - statusEl.textContent = 'Not installed — download to enable the scanner.'; + statusEl.textContent = resolveTranslation('configure.userScanner.install.notInstalled', 'Not installed — download to enable the scanner.'); if (installRow) installRow.style.display = 'flex'; if (updateRow) updateRow.style.display = 'none'; @@ -2345,7 +2345,7 @@ async function _initUserScannerConfigUI(isEnabled) { if (installBtn) { installBtn.onclick = async () => { installBtn.disabled = true; - if (installStatusEl) installStatusEl.textContent = 'Downloading…'; + if (installStatusEl) installStatusEl.textContent = resolveTranslation('configure.userScanner.install.downloading', 'Downloading…'); try { const installResp = await fetch(`${window.location.origin}/api/user-scanner/install`, { method: 'POST', @@ -2354,14 +2354,41 @@ async function _initUserScannerConfigUI(isEnabled) { const result = await installResp.json(); if (result.success) { if (installStatusEl) installStatusEl.textContent = ''; - // Re-init to show installed state - _initUserScannerConfigUI(true); + // Refresh status UI without re-running full init to avoid duplicate listeners + try { + const statusResp = await fetch(`${window.location.origin}/api/user-scanner/status`, { + credentials: 'include', + }); + if (statusResp.ok) { + const latest = await statusResp.json(); + if (latest.installed) { + if (installRow) installRow.style.display = 'none'; + const ver = latest.version || 'unknown'; + statusEl.innerHTML = ''; + const versionText = document.createElement('span'); + versionText.textContent = `Installed — v${ver}`; + statusEl.appendChild(versionText); + const sep = document.createTextNode(' · '); + statusEl.appendChild(sep); + const repoLink = document.createElement('a'); + repoLink.href = 'https://github.com/kaifcodec/user-scanner'; + repoLink.target = '_blank'; + repoLink.rel = 'noopener noreferrer'; + repoLink.textContent = 'GitHub repo ↗'; + repoLink.style.fontSize = 'inherit'; + statusEl.appendChild(repoLink); + if (updateRow) updateRow.style.display = 'flex'; + } + } + } catch { + // Ignore refresh errors; installation itself succeeded + } } else { - if (installStatusEl) installStatusEl.textContent = result.error || 'Installation failed.'; + if (installStatusEl) installStatusEl.textContent = result.error || resolveTranslation('configure.userScanner.install.failed', 'Installation failed.'); installBtn.disabled = false; } } catch (err) { - if (installStatusEl) installStatusEl.textContent = 'Download failed: ' + err.message; + if (installStatusEl) installStatusEl.textContent = resolveTranslation('configure.userScanner.install.downloadFailed', 'Download failed: ') + err.message; installBtn.disabled = false; } }; diff --git a/static/locales/en-US.json b/static/locales/en-US.json index c3d9d3e..3524ff1 100644 --- a/static/locales/en-US.json +++ b/static/locales/en-US.json @@ -1,732 +1,734 @@ { - "buttons": { - "configure": "Configure", - "logout": "Logout", - "save": "Save", - "reset": "Reset", - "reports": "Reports", - "adminLogin": "Admin Login", - "sync": "Sync", - "syncing": "Syncing...", - "syncComplete": "Sync complete", - "syncFailed": "Sync failed" - }, - "index": { - "pageTitle": "Organization Chart", - "header": { - "logoAlt": "Logo", - "title": "Organization Chart", - "subtitle": "Updates daily @ 8:00 PM", - "autoUpdate": { - "enabled": "Updates daily @ {time}", - "enabledWithLastUpdate": "Updates daily @ {time} · Last synced: {lastUpdate}", - "disabled": "Auto-update disabled", - "disabledWithLastUpdate": "Auto-update disabled · Last synced: {lastUpdate}", - "syncing": "Syncing..." - } - }, - "search": { - "placeholder": "Search for employees by name, title, or department...", - "noResults": "No matches found" - }, - "topUser": { - "label": "Top-Level User:", - "placeholder": "Search for top-level user or leave empty for auto-detect...", - "saving": "Saving...", - "saved": "Saved for this session!", - "error": "Unable to save", - "resetting": "Resetting...", - "resetDone": "Reset complete" - }, - "toolbar": { - "layout": { - "vertical": "Vertical layout", - "horizontal": "Horizontal layout", - "fullscreenTitle": "Toggle fullscreen mode (F11)", - "compactTitle": "Toggle compact layout for teams", - "compactTitleAdmin": "Toggle compact layout for large teams (admin)", - "compactTitleGuest": "Toggle compact layout for large teams", - "compactLabel": "Compact Teams", - "compactLabelWithThreshold": "Compact Teams ({count}+ reports)", - "employeeCountTitle": "Toggle employee count badges", - "employeeCountHide": "Hide employee count badges", - "employeeCountShow": "Show employee count badges", - "newEmployeeHighlightTitle": "Toggle new employee highlights", - "newEmployeeHighlightHide": "Hide new employee highlights", - "newEmployeeHighlightShow": "Show new employee highlights", - "profileTitle": "Toggle profile images", - "profileHide": "Hide profile images", - "profileShow": "Show profile images", - "nameTitle": "Toggle name visibility", - "nameHide": "Hide names", - "nameShow": "Show names", - "departmentTitle": "Toggle department visibility", - "departmentHide": "Hide departments", - "departmentShow": "Show departments", - "jobTitleTitle": "Toggle job title visibility", - "jobTitleHide": "Hide job titles", - "jobTitleShow": "Show job titles", - "officeTitle": "Toggle office visibility", - "officeHide": "Hide office locations", - "officeShow": "Show office locations" - }, - "controls": { - "zoomIn": "Zoom in", - "zoomOut": "Zoom out", - "resetZoom": "Reset Zoom", - "expandAll": "Expand All", - "collapseAll": "Collapse All", - "resetHidden": "Reset Hidden", - "resetHiddenTitle": "Show all previously hidden subtrees", - "resetTitles": "Reset Titles", - "resetTitlesTitle": "Restore all job titles to their original values", - "resetDepartments": "Reset Departments", - "resetDepartmentsTitle": "Restore all departments to their original values", - "print": "Print", - "exportVisibleSvg": "SVG", - "exportVisiblePng": "PNG", - "exportVisiblePdf": "PDF", - "exportXlsx": "XLSX (Full)", - "exportXlsxAdmin": "XLSX (Admin)" - } - }, - "status": { - "errorLoading": "Unable to load organization data.", - "updating": "Updating organization chart..." - }, - "alerts": { - "adminLoginExpired": "Your admin session has expired. Please log in again.", - "pngLoadError": "Couldn't load the chart image for export.", - "pngExportError": "Something went wrong while exporting the PNG.", - "pngBlobError": "Couldn't finalize the PNG export.", - "pngSetupError": "Couldn't prepare PNG export: {message}", - "pdfNoData": "There's no chart data to export to PDF.", - "pdfNoChartVisible": "No visible chart to export. Try zooming or expanding first.", - "pdfLibraryMissing": "PDF export library is missing. Refresh the page and try again.", - "pdfGenericError": "PDF export failed: {message}", - "unknownError": "An unknown error occurred.", - "xlsxExportFailed": "XLSX export failed: {message}", - "xlsxExportError": "Unexpected error during XLSX export." - }, - "badges": { - "new": "NEW" - }, - "tree": { - "toggleShow": "Show this subtree", - "toggleHide": "Hide this subtree", - "editTitle": "Edit job title" - }, - "titleEdit": { - "heading": "Edit Job Title or Department", - "instructions": "Set temporary titles or departments for this session. Leave any field blank to restore the original value.", - "employeeLabel": "Employee", - "originalLabel": "Original title", - "overrideLabel": "Custom title", - "overridePlaceholder": "Enter a custom title", - "originalDepartmentLabel": "Original department", - "overrideDepartmentLabel": "Custom department", - "overrideDepartmentPlaceholder": "Enter a custom department", - "save": "Save Changes", - "clear": "Clear Custom Title", - "clearDepartment": "Clear Custom Department", - "cancel": "Cancel" - }, - "employee": { - "noTitle": "No title available", - "editTitleButton": "✏️", - "editDepartmentButton": "✏️", - "titleEditedBadge": "Edited", - "departmentEditedBadge": "Edited", - "detail": { - "nameHidden": "Name hidden", - "nameUnknown": "Unknown name", - "department": "Department", - "departmentUnknown": "Department unavailable", - "email": "Email", - "emailUnknown": "Email unavailable", - "phone": "Phone", - "phoneUnknown": "Phone unavailable", - "businessPhone": "Business Phone", - "businessPhoneUnknown": "Business phone unavailable", - "hireDate": "Hire Date", - "office": "Office", - "location": "Location", - "manager": "Manager", - "directReportsWithCount": "Direct Reports ({count})" - } - }, - "loading": "Loading organization chart..." - }, - "login": { - "pageTitle": "Admin Login - {chartTitle}", - "backToMain": "← Back to Main Page", - "heading": "Admin Login", - "description": "Enter admin password to access configuration", - "password": { - "label": "Password", - "placeholder": "Enter admin password" - }, - "submit": "Login" - }, - "configure": { - "pageTitle": "Configuration - SimpleOrgChart", - "navBack": "← Back to Org Chart", - "header": { - "title": "Configuration Settings", - "subtitle": "Customize your organization chart appearance and behavior" - }, - "unsavedChanges": { - "message": "You have unsaved changes", - "hint": "Save your updates or they will be lost.", - "confirmLeave": "You have unsaved changes. Leave without saving?", - "confirmDiscard": "Discard all unsaved changes?" - }, - "sections": { - "appearance": { - "title": "🎨 Appearance Settings" - }, - "behavior": { - "title": "⚙️ Behavior Settings" - }, - "userScanner": { - "title": "🔍 User Scanner" - }, - "presence": { - "title": "🟢 Teams Presence" - }, - "export": { - "title": "📄 Export to XLSX Options" - }, - "emailReports": { - "title": "📧 Email Reports" - } - }, - "userScanner": { - "enable": { - "label": "User Scanner (OSINT)", - "description": "Enable the User Scanner tool to check email presence across multiple platforms. This uses an external open-source tool (user-scanner) which must be downloaded from the config page before use.", - "toggleLabel": "Enable User Scanner" - }, - "install": { - "button": "Download & Install", - "downloading": "Downloading…", - "success": "Installed successfully.", - "failed": "Installation failed." - } - }, - "presence": { - "enable": { - "label": "Teams Presence Indicators", - "description": "Show a live status dot on each org chart card based on the employee's Microsoft Teams availability. Requires the Presence.Read.All permission in your Entra ID app registration.", - "toggleLabel": "Enable Teams Presence" - } - }, - "emailReports": { - "enable": { - "label": "Automated Email Reports", - "description": "Send scheduled organization chart reports via email after data synchronization", - "toggleLabel": "Enable email reports" - }, - "recipient": { - "label": "Recipient Email", - "description": "Email address(es) to receive automated reports (comma-separated for multiple)", - "placeholder": "reports@yourcompany.com, admin@yourcompany.com" - }, - "frequency": { - "label": "Report Frequency", - "description": "How often to send automated reports", - "daily": "Daily", - "weekly": "Weekly", - "monthly": "Monthly" - }, - "dayOfWeek": { - "label": "Day of Week", - "description": "Select the day of the week to send weekly reports", - "monday": "Monday", - "tuesday": "Tuesday", - "wednesday": "Wednesday", - "thursday": "Thursday", - "friday": "Friday", - "saturday": "Saturday", - "sunday": "Sunday" - }, - "dayOfMonth": { - "label": "Day of Month", - "description": "Which day of the month to send reports", - "first": "First day", - "last": "Last day" - }, - "fileTypes": { - "label": "Attachment Types", - "description": "Select which file formats to include as email attachments", - "xlsx": "XLSX (Excel)", - "png": "PNG (Chart Image)", - "note": "PNG export uses server-side rendering to generate the full organization chart." - }, - "test": { - "label": "Send Reports", - "description": "Test your email configuration or send a report immediately", - "button": "Send Test Email", - "manualSendButton": "Manual Send Now" - } - }, - "appearance": { - "chartTitle": { - "label": "Chart Title", - "description": "Customize the main title displayed in the header", - "placeholder": "Organization Chart" - }, - "headerColor": { - "label": "Header Bar Color", - "description": "Choose the color for the top header bar", - "placeholder": "#0078D4", - "preview": "Preview Header" - }, - "logo": { - "label": "Logo Image", - "description": "Upload a custom logo (max 5MB, recommended height 40px, PNG/JPG)", - "choose": "Choose Logo", - "currentAlt": "Current Logo", - "dropText": "Or drag & drop a PNG/JPG here" - }, - "favicon": { - "label": "Favicon Image", - "description": "Upload a custom favicon for browser tab icon (max 5MB, recommended 32x32px ICO/PNG)", - "choose": "Choose Favicon", - "currentAlt": "Current Favicon", - "dropText": "Or drag & drop an ICO/PNG/JPG here" - }, - "nodeColors": { - "label": "Node Colors by Level", - "description": "Customize colors for each hierarchy level", - "level1": "Level 1:", - "placeholderLevel1": "#90EE90", - "level2": "Level 2:", - "placeholderLevel2": "#FFFFE0", - "level3": "Level 3:", - "placeholderLevel3": "#E0F2FF", - "level4": "Level 4:", - "placeholderLevel4": "#FFE4E1", - "level5": "Level 5:", - "placeholderLevel5": "#E8DFF5", - "level6": "Level 6:", - "placeholderLevel6": "#FFEAA7", - "level7": "Level 7:", - "placeholderLevel7": "#FAD7FF", - "level8": "Level 8:", - "placeholderLevel8": "#D7F8FF" - } - }, - "behavior": { - "autoUpdate": { - "label": "Auto-Update Schedule", - "description": "Set the time and daily schedule for automatic data updates", - "enable": "Enable Auto-Update" - }, - "collapseLevel": { - "label": "Initial Collapse Level", - "description": "How many levels to show expanded on initial load", - "optionLevel1": "Show Level 1 only", - "optionLevel2": "Show 2 levels", - "optionLevel3": "Show 3 levels", - "optionLevel4": "Show 4 levels", - "optionAll": "Show all levels" - }, - "search": { - "label": "Search Settings", - "description": "Configure search behavior", - "autoExpand": "Auto-expand to search results", - "highlight": "Highlight search results", - "highlightDurationLabel": "Highlight fade duration:", - "seconds": "seconds" - }, - "newEmployees": { - "label": "New Employee Highlighting", - "description": "Set the time window for the \"New\" badge; viewers control highlighting from the chart", - "windowLabel": "Consider employees new if joined within:", - "month1": "1 month", - "month3": "3 months", - "month6": "6 months", - "month12": "12 months" - }, - "topLevelUser": { - "label": "Top-Level User", - "description": "Select the root user of the organizational chart (CEO, MD, Chief exec, etc.). Leave empty to auto-detect.", - "selectLabel": "Top-level user", - "placeholder": "Search for user..." - }, - "filtering": { - "label": "Employee Filtering", - "description": "Control which employees appear in the organizational chart", - "hideDisabled": "Hide disabled users", - "hideGuests": "Hide guest users", - "hideNoTitle": "Hide employees with no job title", - "ignoredTitlesLabel": "Ignored job titles:", - "ignoredTitlesPlaceholder": "Search...", - "ignoredDepartmentsLabel": "Ignored departments:", - "ignoredDepartmentsPlaceholder": "Search...", - "ignoredEmployeesLabel": "Ignored employees:", - "ignoredEmployeesPlaceholder": "Search..." - }, - "multiLine": { - "label": "Multi-line Children Threshold", - "description": "Set the number of direct reports at which a manager's team is wrapped into multiple rows.", - "threshold": "Threshold:", - "placeholder": "20" - }, - "print": { - "label": "Print Options", - "description": "Configure print layout settings", - "landscape": "Landscape", - "portrait": "Portrait", - "a4": "A4", - "letter": "Letter", - "a3": "A3" - } - }, - "export": { - "columnVisibility": { - "label": "Column Visibility", - "description": "Choose which fields appear in XLSX exports. “Show Admin Only” keeps the column hidden for regular viewers but includes it when an administrator exports." - }, - "columns": { - "name": "Name", - "title": "Title", - "department": "Department", - "email": "Email", - "phone": "Phone", - "businessPhone": "Business Phone", - "hireDate": "Hire Date", - "country": "Country", - "state": "State", - "city": "City", - "office": "Office", - "manager": "Manager" - }, - "options": { - "show": "Show", - "hide": "Hide", - "admin": "Show Admin Only" - } - }, - "data": { - "manualUpdate": { - "lastUpdatedPending": "Pending", - "lastUpdatedUpdating": "Updating...", - "statusComplete": "✔ Update complete", - "statusFailed": "✗ Update failed" - } - }, - "configTransfer": { - "title": "📦 Configuration Transfer", - "export": { - "label": "Export Configuration", - "description": "Download all settings as a JSON file. Employee data is not included.", - "button": "Export" - }, - "import": { - "label": "Import Configuration", - "description": "Upload a previously exported JSON file to restore settings.", - "button": "Import" - }, - "reset": { - "label": "Reset to Defaults", - "description": "Restore all settings to their factory defaults. Employee data is not affected.", - "button": "Reset" - }, - "flash": { - "importSuccess": "Configuration imported successfully.", - "importError": "Failed to import configuration: " - } - }, - "buttons": { - "resetDefault": "Reset to Default", - "resetAllNodeColors": "Reset All Node Colors", - "resetColumns": "Reset Columns to Default", - "clear": "Clear", - "discard": "Discard Changes", - "saveAll": "Save All Settings", - "resetAll": "Reset Everything to Defaults", - "logout": "Logout" - } - }, - "reports": { - "pageTitle": "Reports - SimpleOrgChart", - "navBack": "← Back to Org Chart", - "header": { - "title": "Admin Reports", - "subtitle": "Analyze data quality and export insights" - }, - "selector": { - "label": "Report type", - "options": { - "missingManager": "Employees without managers", - "lastLogins": "Users by last sign-in activity", - "hiredThisYear": "Employees hired in the last 365 days", - "filteredUsers": "Users hidden by filters", - "userScanner": "User Scanner (OSINT)" - } - }, - "userScanner": { - "searchLabel": "Scan a user", - "searchPlaceholder": "Search employee or enter any email address...", - "scanButton": "Run Scan", - "tabs": { - "single": "Individual", - "all": "Organization" - }, - "siteFilter": { - "label": "Filter by sites", - "placeholder": "Type a site name...", - "reset": "Reset" - }, - "categoryFilter": { - "label": "Filter by categories", - "placeholder": "Type a category...", - "reset": "Reset" - }, - "options": { - "allowLoud": "Include loud sites (may notify the target)", - "onlyFound": "Only show registered / found results" - }, - "fullScan": { - "title": "Full Organization Scan", - "description": "Scan all employees by email. Results are downloaded automatically when complete.", - "filtersTitle": "User Filters", - "emailPlaceholder": "(Optional) Email to send report to...", - "button": "Run Full Scan" - } - }, - "buttons": { - "refresh": "Refresh Data", - "refreshTooltip": "Regenerate the report with the latest directory data", - "export": "Export XLSX", - "exportTooltip": "Download this report as an Excel workbook", - "exportPdf": "Export PDF", - "exportPdfTooltip": "Download this report as a PDF document" - }, - "filters": { - "title": "Filters", - "groups": { - "mailboxTypes": "Mailbox type", - "accountStatus": "Account status", - "licenseStatus": "License status", - "userScope": "User type" - }, - "includeUserMailboxes": { - "label": "User" - }, - "includeSharedMailboxes": { - "label": "Shared" - }, - "includeRoomEquipmentMailboxes": { - "label": "Room/Equipment" - }, - "includeEnabled": { - "label": "Enabled" - }, - "includeDisabled": { - "label": "Disabled" - }, - "includeLicensed": { - "label": "Licensed" - }, - "includeUnlicensed": { - "label": "Unlicensed" - }, - "inactiveDays": { - "label": "Inactive for at least", - "options": { - "all": "All activity", - "thirty": "30 days", - "sixty": "60 days", - "ninety": "90 days", - "oneEighty": "180 days", - "year": "365 days", - "never": "Never signed in" - } - }, - "inactiveDaysMax": { - "label": "Inactive for at most", - "options": { - "noLimit": "No limit", - "thirty": "30 days", - "sixty": "60 days", - "ninety": "90 days", - "oneEighty": "180 days", - "year": "365 days", - "twoYears": "730 days", - "threeYears": "1095 days" - } - }, - "includeGuests": { - "label": "Guest" - }, - "includeMembers": { - "label": "Member" - } - }, - "summary": { - "totalLabel": "Employees without managers", - "generatedLabel": "Last Generated", - "generatedPending": "Pending", - "licensesLabel": "Total Licenses" - }, - "table": { - "title": "Employees without managers", - "loading": "Loading report...", - "countSummary": "Showing {count} employees without managers", - "empty": "No users are currently missing manager information. Great job!", - "columns": { - "name": "Name", - "title": "Title", - "department": "Department", - "email": "Email", - "phone": "Phone", - "location": "Location", - "manager": "Manager", - "reason": "Reason", - "userPrincipalName": "User Principal Name", - "usageLocation": "Usage Location", - "hireDate": "Hire Date", - "daysSinceHire": "Days Since Hire", - "licenseCount": "License Count", - "licenses": "Licenses", - "lastActivityDate": "Most recent sign-in", - "daysSinceMostRecentSignIn": "Days since most recent sign-in", - "lastInteractiveSignIn": "Last interactive sign-in", - "daysSinceInteractiveSignIn": "Days since interactive sign-in", - "lastNonInteractiveSignIn": "Last non-interactive sign-in", - "daysSinceNonInteractiveSignIn": "Days since non-interactive sign-in", - "neverSignedIn": "Never signed in" - }, - "reasonLabels": { - "no_manager": "No manager", - "manager_not_found": "Manager not found", - "detached": "Detached hierarchy", - "filtered": "Filtered", - "unknown": "Unknown reason" - }, - "neverSignedIn": "Never signed in" - }, - "types": { - "lastLogins": { - "summaryLabel": "Users by last sign-in activity", - "tableTitle": "Users by last sign-in activity", - "empty": "All monitored users have signed in within the selected period.", - "countSummary": "Showing {count} users matching the report criteria" - }, - "hiredThisYear": { - "summaryLabel": "Employees hired in the last 365 days", - "tableTitle": "Employees hired in the last 365 days", - "empty": "No employees have been hired in the last 365 days.", - "countSummary": "Showing {count} employees hired in the last 365 days" - }, - "filteredLicensed": { - "columns": { - "filterReasons": "Reason" - }, - "reason": { - "disabled": "Disabled", - "guest": "Guest", - "noTitle": "Title missing", - "ignoredTitle": "Title filtered", - "ignoredDepartment": "Department filtered", - "ignoredEmployee": "User filtered" - } - }, - "filteredUsers": { - "summaryLabel": "Users hidden by filters", - "tableTitle": "Users hidden by org chart filters", - "empty": "No users are currently hidden by your filters.", - "countSummary": "Showing {count} users hidden by filters" - }, - "userScanner": { - "summaryLabel": "User Scanner Results", - "tableTitle": "User Scanner OSINT Results", - "empty": "No scan results yet. Run a scan to see results here.", - "countSummary": "Showing results for {count} employees", - "noResults": "No results found for this scan.", - "scanning": "Scanning…", - "singleResultTitle": "Scan Results", - "columns": { - "totalChecked": "Sites Checked", - "registeredCount": "Registered" - }, - "fullScan": { - "starting": "Starting full organization scan…", - "started": "Full scan started for {count} employees.", - "willNotify": "Report will also be emailed to {email}.", - "running": "A full scan is currently running…", - "progressStatus": "Scanning {current} of {total} employees…", - "stop": "Stop Scan", - "stopping": "Stopping scan after current employee…", - "stopped": "Scan stopped. Partial results available below.", - "complete": "Scan complete. Download available below.", - "failed": "Scan failed: {error}", - "terminalTitle": "Scan Output", - "historyTitle": "Recent Scan Results", - "historyEmployees": "employees", - "historyRegistered": "registered", - "download": "Download", - "clearHistory": "Clear" - } - } - }, - "errors": { - "loadFailed": "Unable to load the report.", - "exportFailed": "Unable to export the report.", - "initializationFailed": "Failed to initialize the reports page.", - "pdfLibraryMissing": "PDF export library is not available. Please refresh the page.", - "noDataToExport": "No data available to export.", - "noTableToExport": "No table data found to export.", - "pdfExportFailed": "PDF export failed: {message}" - } - }, - "searchTest": { - "pageTitle": "Search Functionality Test", - "heading": "🔍 Search Functionality Test & Debug", - "sections": { - "checkData": { - "title": "1. Check Data File Status", - "checkButton": "Check Data File", - "debugButton": "Debug Search System" - }, - "forceUpdate": { - "title": "2. Force Data Update", - "description": "If the data file doesn't exist or is empty, force an update:", - "button": "Force Update Now" - }, - "testSearch": { - "title": "3. Test Search", - "placeholder": "Enter search term (min 2 chars)", - "button": "Test Search" - }, - "viewAll": { - "title": "4. View All Employees", - "button": "Load All Employees" - } - } - }, - "presence": { - "activity": { - "Available": "Available", - "Away": "Away", - "BeRightBack": "Be Right Back", - "Busy": "Busy", - "DoNotDisturb": "Do Not Disturb", - "InACall": "In a Call", - "InAConferenceCall": "In a Conference Call", - "InAMeeting": "In a Meeting", - "Inactive": "Inactive", - "Offline": "Offline", - "OffWork": "Off Work", - "OutOfOffice": "Out of Office", - "PresenceUnknown": "Unknown", - "Presenting": "Presenting", - "UrgentInterruptionsOnly": "Do Not Disturb", - "AvailableIdle": "Available (Idle)", - "BusyIdle": "Busy (Idle)" - } - } + "buttons": { + "configure": "Configure", + "logout": "Logout", + "save": "Save", + "reset": "Reset", + "reports": "Reports", + "adminLogin": "Admin Login", + "sync": "Sync", + "syncing": "Syncing...", + "syncComplete": "Sync complete", + "syncFailed": "Sync failed" + }, + "index": { + "pageTitle": "Organization Chart", + "header": { + "logoAlt": "Logo", + "title": "Organization Chart", + "subtitle": "Updates daily @ 8:00 PM", + "autoUpdate": { + "enabled": "Updates daily @ {time}", + "enabledWithLastUpdate": "Updates daily @ {time} · Last synced: {lastUpdate}", + "disabled": "Auto-update disabled", + "disabledWithLastUpdate": "Auto-update disabled · Last synced: {lastUpdate}", + "syncing": "Syncing..." + } + }, + "search": { + "placeholder": "Search for employees by name, title, or department...", + "noResults": "No matches found" + }, + "topUser": { + "label": "Top-Level User:", + "placeholder": "Search for top-level user or leave empty for auto-detect...", + "saving": "Saving...", + "saved": "Saved for this session!", + "error": "Unable to save", + "resetting": "Resetting...", + "resetDone": "Reset complete" + }, + "toolbar": { + "layout": { + "vertical": "Vertical layout", + "horizontal": "Horizontal layout", + "fullscreenTitle": "Toggle fullscreen mode (F11)", + "compactTitle": "Toggle compact layout for teams", + "compactTitleAdmin": "Toggle compact layout for large teams (admin)", + "compactTitleGuest": "Toggle compact layout for large teams", + "compactLabel": "Compact Teams", + "compactLabelWithThreshold": "Compact Teams ({count}+ reports)", + "employeeCountTitle": "Toggle employee count badges", + "employeeCountHide": "Hide employee count badges", + "employeeCountShow": "Show employee count badges", + "newEmployeeHighlightTitle": "Toggle new employee highlights", + "newEmployeeHighlightHide": "Hide new employee highlights", + "newEmployeeHighlightShow": "Show new employee highlights", + "profileTitle": "Toggle profile images", + "profileHide": "Hide profile images", + "profileShow": "Show profile images", + "nameTitle": "Toggle name visibility", + "nameHide": "Hide names", + "nameShow": "Show names", + "departmentTitle": "Toggle department visibility", + "departmentHide": "Hide departments", + "departmentShow": "Show departments", + "jobTitleTitle": "Toggle job title visibility", + "jobTitleHide": "Hide job titles", + "jobTitleShow": "Show job titles", + "officeTitle": "Toggle office visibility", + "officeHide": "Hide office locations", + "officeShow": "Show office locations" + }, + "controls": { + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "resetZoom": "Reset Zoom", + "expandAll": "Expand All", + "collapseAll": "Collapse All", + "resetHidden": "Reset Hidden", + "resetHiddenTitle": "Show all previously hidden subtrees", + "resetTitles": "Reset Titles", + "resetTitlesTitle": "Restore all job titles to their original values", + "resetDepartments": "Reset Departments", + "resetDepartmentsTitle": "Restore all departments to their original values", + "print": "Print", + "exportVisibleSvg": "SVG", + "exportVisiblePng": "PNG", + "exportVisiblePdf": "PDF", + "exportXlsx": "XLSX (Full)", + "exportXlsxAdmin": "XLSX (Admin)" + } + }, + "status": { + "errorLoading": "Unable to load organization data.", + "updating": "Updating organization chart..." + }, + "alerts": { + "adminLoginExpired": "Your admin session has expired. Please log in again.", + "pngLoadError": "Couldn't load the chart image for export.", + "pngExportError": "Something went wrong while exporting the PNG.", + "pngBlobError": "Couldn't finalize the PNG export.", + "pngSetupError": "Couldn't prepare PNG export: {message}", + "pdfNoData": "There's no chart data to export to PDF.", + "pdfNoChartVisible": "No visible chart to export. Try zooming or expanding first.", + "pdfLibraryMissing": "PDF export library is missing. Refresh the page and try again.", + "pdfGenericError": "PDF export failed: {message}", + "unknownError": "An unknown error occurred.", + "xlsxExportFailed": "XLSX export failed: {message}", + "xlsxExportError": "Unexpected error during XLSX export." + }, + "badges": { + "new": "NEW" + }, + "tree": { + "toggleShow": "Show this subtree", + "toggleHide": "Hide this subtree", + "editTitle": "Edit job title" + }, + "titleEdit": { + "heading": "Edit Job Title or Department", + "instructions": "Set temporary titles or departments for this session. Leave any field blank to restore the original value.", + "employeeLabel": "Employee", + "originalLabel": "Original title", + "overrideLabel": "Custom title", + "overridePlaceholder": "Enter a custom title", + "originalDepartmentLabel": "Original department", + "overrideDepartmentLabel": "Custom department", + "overrideDepartmentPlaceholder": "Enter a custom department", + "save": "Save Changes", + "clear": "Clear Custom Title", + "clearDepartment": "Clear Custom Department", + "cancel": "Cancel" + }, + "employee": { + "noTitle": "No title available", + "editTitleButton": "✏️", + "editDepartmentButton": "✏️", + "titleEditedBadge": "Edited", + "departmentEditedBadge": "Edited", + "detail": { + "nameHidden": "Name hidden", + "nameUnknown": "Unknown name", + "department": "Department", + "departmentUnknown": "Department unavailable", + "email": "Email", + "emailUnknown": "Email unavailable", + "phone": "Phone", + "phoneUnknown": "Phone unavailable", + "businessPhone": "Business Phone", + "businessPhoneUnknown": "Business phone unavailable", + "hireDate": "Hire Date", + "office": "Office", + "location": "Location", + "manager": "Manager", + "directReportsWithCount": "Direct Reports ({count})" + } + }, + "loading": "Loading organization chart..." + }, + "login": { + "pageTitle": "Admin Login - {chartTitle}", + "backToMain": "← Back to Main Page", + "heading": "Admin Login", + "description": "Enter admin password to access configuration", + "password": { + "label": "Password", + "placeholder": "Enter admin password" + }, + "submit": "Login" + }, + "configure": { + "pageTitle": "Configuration - SimpleOrgChart", + "navBack": "← Back to Org Chart", + "header": { + "title": "Configuration Settings", + "subtitle": "Customize your organization chart appearance and behavior" + }, + "unsavedChanges": { + "message": "You have unsaved changes", + "hint": "Save your updates or they will be lost.", + "confirmLeave": "You have unsaved changes. Leave without saving?", + "confirmDiscard": "Discard all unsaved changes?" + }, + "sections": { + "appearance": { + "title": "🎨 Appearance Settings" + }, + "behavior": { + "title": "⚙️ Behavior Settings" + }, + "userScanner": { + "title": "🔍 User Scanner" + }, + "presence": { + "title": "🟢 Teams Presence" + }, + "export": { + "title": "📄 Export to XLSX Options" + }, + "emailReports": { + "title": "📧 Email Reports" + } + }, + "userScanner": { + "enable": { + "label": "User Scanner (OSINT)", + "description": "Enable the User Scanner tool to check email presence across multiple platforms. This uses an external open-source tool (user-scanner) which must be downloaded from the config page before use.", + "toggleLabel": "Enable User Scanner" + }, + "install": { + "button": "Download & Install", + "downloading": "Downloading…", + "success": "Installed successfully.", + "failed": "Installation failed.", + "notInstalled": "Not installed — download to enable the scanner.", + "downloadFailed": "Download failed: " + } + }, + "presence": { + "enable": { + "label": "Teams Presence Indicators", + "description": "Show a live status dot on each org chart card based on the employee's Microsoft Teams availability. Requires the Presence.Read.All permission in your Entra ID app registration.", + "toggleLabel": "Enable Teams Presence" + } + }, + "emailReports": { + "enable": { + "label": "Automated Email Reports", + "description": "Send scheduled organization chart reports via email after data synchronization", + "toggleLabel": "Enable email reports" + }, + "recipient": { + "label": "Recipient Email", + "description": "Email address(es) to receive automated reports (comma-separated for multiple)", + "placeholder": "reports@yourcompany.com, admin@yourcompany.com" + }, + "frequency": { + "label": "Report Frequency", + "description": "How often to send automated reports", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "dayOfWeek": { + "label": "Day of Week", + "description": "Select the day of the week to send weekly reports", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "dayOfMonth": { + "label": "Day of Month", + "description": "Which day of the month to send reports", + "first": "First day", + "last": "Last day" + }, + "fileTypes": { + "label": "Attachment Types", + "description": "Select which file formats to include as email attachments", + "xlsx": "XLSX (Excel)", + "png": "PNG (Chart Image)", + "note": "PNG export uses server-side rendering to generate the full organization chart." + }, + "test": { + "label": "Send Reports", + "description": "Test your email configuration or send a report immediately", + "button": "Send Test Email", + "manualSendButton": "Manual Send Now" + } + }, + "appearance": { + "chartTitle": { + "label": "Chart Title", + "description": "Customize the main title displayed in the header", + "placeholder": "Organization Chart" + }, + "headerColor": { + "label": "Header Bar Color", + "description": "Choose the color for the top header bar", + "placeholder": "#0078D4", + "preview": "Preview Header" + }, + "logo": { + "label": "Logo Image", + "description": "Upload a custom logo (max 5MB, recommended height 40px, PNG/JPG)", + "choose": "Choose Logo", + "currentAlt": "Current Logo", + "dropText": "Or drag & drop a PNG/JPG here" + }, + "favicon": { + "label": "Favicon Image", + "description": "Upload a custom favicon for browser tab icon (max 5MB, recommended 32x32px ICO/PNG)", + "choose": "Choose Favicon", + "currentAlt": "Current Favicon", + "dropText": "Or drag & drop an ICO/PNG/JPG here" + }, + "nodeColors": { + "label": "Node Colors by Level", + "description": "Customize colors for each hierarchy level", + "level1": "Level 1:", + "placeholderLevel1": "#90EE90", + "level2": "Level 2:", + "placeholderLevel2": "#FFFFE0", + "level3": "Level 3:", + "placeholderLevel3": "#E0F2FF", + "level4": "Level 4:", + "placeholderLevel4": "#FFE4E1", + "level5": "Level 5:", + "placeholderLevel5": "#E8DFF5", + "level6": "Level 6:", + "placeholderLevel6": "#FFEAA7", + "level7": "Level 7:", + "placeholderLevel7": "#FAD7FF", + "level8": "Level 8:", + "placeholderLevel8": "#D7F8FF" + } + }, + "behavior": { + "autoUpdate": { + "label": "Auto-Update Schedule", + "description": "Set the time and daily schedule for automatic data updates", + "enable": "Enable Auto-Update" + }, + "collapseLevel": { + "label": "Initial Collapse Level", + "description": "How many levels to show expanded on initial load", + "optionLevel1": "Show Level 1 only", + "optionLevel2": "Show 2 levels", + "optionLevel3": "Show 3 levels", + "optionLevel4": "Show 4 levels", + "optionAll": "Show all levels" + }, + "search": { + "label": "Search Settings", + "description": "Configure search behavior", + "autoExpand": "Auto-expand to search results", + "highlight": "Highlight search results", + "highlightDurationLabel": "Highlight fade duration:", + "seconds": "seconds" + }, + "newEmployees": { + "label": "New Employee Highlighting", + "description": "Set the time window for the \"New\" badge; viewers control highlighting from the chart", + "windowLabel": "Consider employees new if joined within:", + "month1": "1 month", + "month3": "3 months", + "month6": "6 months", + "month12": "12 months" + }, + "topLevelUser": { + "label": "Top-Level User", + "description": "Select the root user of the organizational chart (CEO, MD, Chief exec, etc.). Leave empty to auto-detect.", + "selectLabel": "Top-level user", + "placeholder": "Search for user..." + }, + "filtering": { + "label": "Employee Filtering", + "description": "Control which employees appear in the organizational chart", + "hideDisabled": "Hide disabled users", + "hideGuests": "Hide guest users", + "hideNoTitle": "Hide employees with no job title", + "ignoredTitlesLabel": "Ignored job titles:", + "ignoredTitlesPlaceholder": "Search...", + "ignoredDepartmentsLabel": "Ignored departments:", + "ignoredDepartmentsPlaceholder": "Search...", + "ignoredEmployeesLabel": "Ignored employees:", + "ignoredEmployeesPlaceholder": "Search..." + }, + "multiLine": { + "label": "Multi-line Children Threshold", + "description": "Set the number of direct reports at which a manager's team is wrapped into multiple rows.", + "threshold": "Threshold:", + "placeholder": "20" + }, + "print": { + "label": "Print Options", + "description": "Configure print layout settings", + "landscape": "Landscape", + "portrait": "Portrait", + "a4": "A4", + "letter": "Letter", + "a3": "A3" + } + }, + "export": { + "columnVisibility": { + "label": "Column Visibility", + "description": "Choose which fields appear in XLSX exports. “Show Admin Only” keeps the column hidden for regular viewers but includes it when an administrator exports." + }, + "columns": { + "name": "Name", + "title": "Title", + "department": "Department", + "email": "Email", + "phone": "Phone", + "businessPhone": "Business Phone", + "hireDate": "Hire Date", + "country": "Country", + "state": "State", + "city": "City", + "office": "Office", + "manager": "Manager" + }, + "options": { + "show": "Show", + "hide": "Hide", + "admin": "Show Admin Only" + } + }, + "data": { + "manualUpdate": { + "lastUpdatedPending": "Pending", + "lastUpdatedUpdating": "Updating...", + "statusComplete": "✔ Update complete", + "statusFailed": "✗ Update failed" + } + }, + "configTransfer": { + "title": "📦 Configuration Transfer", + "export": { + "label": "Export Configuration", + "description": "Download all settings as a JSON file. Employee data is not included.", + "button": "Export" + }, + "import": { + "label": "Import Configuration", + "description": "Upload a previously exported JSON file to restore settings.", + "button": "Import" + }, + "reset": { + "label": "Reset to Defaults", + "description": "Restore all settings to their factory defaults. Employee data is not affected.", + "button": "Reset" + }, + "flash": { + "importSuccess": "Configuration imported successfully.", + "importError": "Failed to import configuration: " + } + }, + "buttons": { + "resetDefault": "Reset to Default", + "resetAllNodeColors": "Reset All Node Colors", + "resetColumns": "Reset Columns to Default", + "clear": "Clear", + "discard": "Discard Changes", + "saveAll": "Save All Settings", + "resetAll": "Reset Everything to Defaults", + "logout": "Logout" + } + }, + "reports": { + "pageTitle": "Reports - SimpleOrgChart", + "navBack": "← Back to Org Chart", + "header": { + "title": "Admin Reports", + "subtitle": "Analyze data quality and export insights" + }, + "selector": { + "label": "Report type", + "options": { + "missingManager": "Employees without managers", + "lastLogins": "Users by last sign-in activity", + "hiredThisYear": "Employees hired in the last 365 days", + "filteredUsers": "Users hidden by filters", + "userScanner": "User Scanner (OSINT)" + } + }, + "userScanner": { + "searchLabel": "Scan a user", + "searchPlaceholder": "Search employee or enter any email address...", + "scanButton": "Run Scan", + "tabs": { + "single": "Individual", + "all": "Organization" + }, + "siteFilter": { + "label": "Filter by sites", + "placeholder": "Type a site name...", + "reset": "Reset" + }, + "categoryFilter": { + "label": "Filter by categories", + "placeholder": "Type a category...", + "reset": "Reset" + }, + "options": { + "allowLoud": "Include loud sites (may notify the target)", + "onlyFound": "Only show registered / found results" + }, + "fullScan": { + "title": "Full Organization Scan", + "description": "Scan all employees by email. Results are downloaded automatically when complete.", + "filtersTitle": "User Filters", + "emailPlaceholder": "(Optional) Email to send report to...", + "button": "Run Full Scan" + } + }, + "buttons": { + "refresh": "Refresh Data", + "refreshTooltip": "Regenerate the report with the latest directory data", + "export": "Export XLSX", + "exportTooltip": "Download this report as an Excel workbook", + "exportPdf": "Export PDF", + "exportPdfTooltip": "Download this report as a PDF document" + }, + "filters": { + "title": "Filters", + "groups": { + "mailboxTypes": "Mailbox type", + "accountStatus": "Account status", + "licenseStatus": "License status", + "userScope": "User type" + }, + "includeUserMailboxes": { + "label": "User" + }, + "includeSharedMailboxes": { + "label": "Shared" + }, + "includeRoomEquipmentMailboxes": { + "label": "Room/Equipment" + }, + "includeEnabled": { + "label": "Enabled" + }, + "includeDisabled": { + "label": "Disabled" + }, + "includeLicensed": { + "label": "Licensed" + }, + "includeUnlicensed": { + "label": "Unlicensed" + }, + "inactiveDays": { + "label": "Inactive for at least", + "options": { + "all": "All activity", + "thirty": "30 days", + "sixty": "60 days", + "ninety": "90 days", + "oneEighty": "180 days", + "year": "365 days", + "never": "Never signed in" + } + }, + "inactiveDaysMax": { + "label": "Inactive for at most", + "options": { + "noLimit": "No limit", + "thirty": "30 days", + "sixty": "60 days", + "ninety": "90 days", + "oneEighty": "180 days", + "year": "365 days", + "twoYears": "730 days", + "threeYears": "1095 days" + } + }, + "includeGuests": { + "label": "Guest" + }, + "includeMembers": { + "label": "Member" + } + }, + "summary": { + "totalLabel": "Employees without managers", + "generatedLabel": "Last Generated", + "generatedPending": "Pending", + "licensesLabel": "Total Licenses" + }, + "table": { + "title": "Employees without managers", + "loading": "Loading report...", + "countSummary": "Showing {count} employees without managers", + "empty": "No users are currently missing manager information. Great job!", + "columns": { + "name": "Name", + "title": "Title", + "department": "Department", + "email": "Email", + "phone": "Phone", + "location": "Location", + "manager": "Manager", + "reason": "Reason", + "userPrincipalName": "User Principal Name", + "usageLocation": "Usage Location", + "hireDate": "Hire Date", + "daysSinceHire": "Days Since Hire", + "licenseCount": "License Count", + "licenses": "Licenses", + "lastActivityDate": "Most recent sign-in", + "daysSinceMostRecentSignIn": "Days since most recent sign-in", + "lastInteractiveSignIn": "Last interactive sign-in", + "daysSinceInteractiveSignIn": "Days since interactive sign-in", + "lastNonInteractiveSignIn": "Last non-interactive sign-in", + "daysSinceNonInteractiveSignIn": "Days since non-interactive sign-in", + "neverSignedIn": "Never signed in" + }, + "reasonLabels": { + "no_manager": "No manager", + "manager_not_found": "Manager not found", + "detached": "Detached hierarchy", + "filtered": "Filtered", + "unknown": "Unknown reason" + }, + "neverSignedIn": "Never signed in" + }, + "types": { + "lastLogins": { + "summaryLabel": "Users by last sign-in activity", + "tableTitle": "Users by last sign-in activity", + "empty": "All monitored users have signed in within the selected period.", + "countSummary": "Showing {count} users matching the report criteria" + }, + "hiredThisYear": { + "summaryLabel": "Employees hired in the last 365 days", + "tableTitle": "Employees hired in the last 365 days", + "empty": "No employees have been hired in the last 365 days.", + "countSummary": "Showing {count} employees hired in the last 365 days" + }, + "filteredLicensed": { + "columns": { + "filterReasons": "Reason" + }, + "reason": { + "disabled": "Disabled", + "guest": "Guest", + "noTitle": "Title missing", + "ignoredTitle": "Title filtered", + "ignoredDepartment": "Department filtered", + "ignoredEmployee": "User filtered" + } + }, + "filteredUsers": { + "summaryLabel": "Users hidden by filters", + "tableTitle": "Users hidden by org chart filters", + "empty": "No users are currently hidden by your filters.", + "countSummary": "Showing {count} users hidden by filters" + }, + "userScanner": { + "summaryLabel": "User Scanner Results", + "tableTitle": "User Scanner OSINT Results", + "empty": "No scan results yet. Run a scan to see results here.", + "countSummary": "Showing results for {count} employees", + "noResults": "No results found for this scan.", + "scanning": "Scanning…", + "singleResultTitle": "Scan Results", + "columns": { + "totalChecked": "Sites Checked", + "registeredCount": "Registered" + }, + "fullScan": { + "starting": "Starting full organization scan…", + "started": "Full scan started for {count} employees.", + "willNotify": "Report will also be emailed to {email}.", + "running": "A full scan is currently running…", + "progressStatus": "Scanning {current} of {total} employees…", + "stop": "Stop Scan", + "stopping": "Stopping scan after current employee…", + "stopped": "Scan stopped. Partial results available below.", + "complete": "Scan complete. Download available below.", + "failed": "Scan failed: {error}", + "terminalTitle": "Scan Output", + "historyTitle": "Recent Scan Results", + "historyEmployees": "employees", + "historyRegistered": "registered", + "download": "Download", + "clearHistory": "Clear" + } + } + }, + "errors": { + "loadFailed": "Unable to load the report.", + "exportFailed": "Unable to export the report.", + "initializationFailed": "Failed to initialize the reports page.", + "pdfLibraryMissing": "PDF export library is not available. Please refresh the page.", + "noDataToExport": "No data available to export.", + "noTableToExport": "No table data found to export.", + "pdfExportFailed": "PDF export failed: {message}" + } + }, + "searchTest": { + "pageTitle": "Search Functionality Test", + "heading": "🔍 Search Functionality Test & Debug", + "sections": { + "checkData": { + "title": "1. Check Data File Status", + "checkButton": "Check Data File", + "debugButton": "Debug Search System" + }, + "forceUpdate": { + "title": "2. Force Data Update", + "description": "If the data file doesn't exist or is empty, force an update:", + "button": "Force Update Now" + }, + "testSearch": { + "title": "3. Test Search", + "placeholder": "Enter search term (min 2 chars)", + "button": "Test Search" + }, + "viewAll": { + "title": "4. View All Employees", + "button": "Load All Employees" + } + } + }, + "presence": { + "activity": { + "Available": "Available", + "Away": "Away", + "BeRightBack": "Be Right Back", + "Busy": "Busy", + "DoNotDisturb": "Do Not Disturb", + "InACall": "In a Call", + "InAConferenceCall": "In a Conference Call", + "InAMeeting": "In a Meeting", + "Inactive": "Inactive", + "Offline": "Offline", + "OffWork": "Off Work", + "OutOfOffice": "Out of Office", + "PresenceUnknown": "Unknown", + "Presenting": "Presenting", + "UrgentInterruptionsOnly": "Do Not Disturb", + "AvailableIdle": "Available (Idle)", + "BusyIdle": "Busy (Idle)" + } + } } From f5e015a075d86ab5350ffa29aef8f1433b15b976 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:16:15 +0200 Subject: [PATCH 3/6] Harden settings file concurrency, email key isolation, and import guard (#51) * Initial plan * Fix inter-process locking, email key restriction, dict check, and conditional settings save Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> * Remove committed lock file and add config/*.lock to .gitignore Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> * Fix fd leak in lock acquire, remove locked() race, and extract _filter_email_keys helper Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> --- .gitignore | 3 ++ simple_org_chart/app_main.py | 5 +- simple_org_chart/email_config.py | 24 ++++++---- simple_org_chart/settings.py | 79 ++++++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 0bb3ba4..0554d6e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ repositories/ __pycache__/ *.pyc flask_session/ + +# Runtime lock files +config/*.lock diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py index a54b0da..3bc002b 100644 --- a/simple_org_chart/app_main.py +++ b/simple_org_chart/app_main.py @@ -1200,7 +1200,10 @@ def import_settings(): elif key in email_keys: email_data[key] = value - settings_ok = save_settings(settings_data) + # Only save settings if at least one valid settings key was provided + settings_ok = True + if settings_data: + settings_ok = save_settings(settings_data) email_ok = True if email_data: diff --git a/simple_org_chart/email_config.py b/simple_org_chart/email_config.py index 57ef6aa..b4019ab 100644 --- a/simple_org_chart/email_config.py +++ b/simple_org_chart/email_config.py @@ -31,6 +31,12 @@ # Keys that belong to the email configuration _EMAIL_KEYS = set(DEFAULT_EMAIL_CONFIG.keys()) + +def _filter_email_keys(source: Dict[str, Any]) -> Dict[str, Any]: + """Return a new dict containing only keys that belong to the email config.""" + return {key: source[key] for key in _EMAIL_KEYS if key in source} + + def get_smtp_config() -> Dict[str, Any]: """Load SMTP configuration from environment variables.""" # Get encryption setting (TLS, SSL, or None) @@ -61,14 +67,16 @@ def load_email_config() -> Dict[str, Any]: try: with SETTINGS_FILE.open("r", encoding="utf-8") as handle: stored = json.load(handle) - merged = DEFAULT_EMAIL_CONFIG.copy() - for key in _EMAIL_KEYS: - if key in stored: - merged[key] = stored[key] - return merged except Exception as error: logger.error("Error loading email config: %s", error) - + else: + if not isinstance(stored, dict): + logger.warning("app_settings.json does not contain a JSON object; using email defaults") + return DEFAULT_EMAIL_CONFIG.copy() + merged = DEFAULT_EMAIL_CONFIG.copy() + merged.update(_filter_email_keys(stored)) + return merged + return DEFAULT_EMAIL_CONFIG.copy() @@ -76,9 +84,9 @@ def save_email_config(config: Dict[str, Any]) -> bool: """Save email configuration into app_settings.json.""" SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) - # Merge with defaults + # Restrict to known email keys only — never let caller overwrite unrelated settings persisted = DEFAULT_EMAIL_CONFIG.copy() - persisted.update(config) + persisted.update(_filter_email_keys(config)) if 'lastSent' not in config: # lastSent will be preserved from the existing file inside the lock below diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py index 67763d1..2913637 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -13,10 +13,83 @@ from .config import SETTINGS_FILE -# Shared in-process lock protecting all reads/writes to SETTINGS_FILE. + +class _InterProcessSettingsFileLock: + """Combined thread + process lock for protecting SETTINGS_FILE. + + Serialises concurrent access across both threads within a single worker + and across multiple Gunicorn worker processes by combining a + ``threading.Lock`` with an ``fcntl.flock`` advisory file lock. On + platforms where ``fcntl`` is unavailable (e.g. Windows) the class + falls back gracefully to thread-only locking. + """ + + def __init__(self, lock_file_path: str) -> None: + self._lock_file_path = lock_file_path + self._thread_lock = threading.Lock() + self._fd: int | None = None + + def acquire(self, blocking: bool = True) -> bool: + acquired = self._thread_lock.acquire(blocking) + if not acquired: + return False + fd = None + try: + import fcntl + fd = os.open(self._lock_file_path, os.O_CREAT | os.O_RDWR, 0o600) + flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) + fcntl.flock(fd, flags) + self._fd = fd + except ImportError: + # fcntl not available (Windows); thread lock is sufficient. + if fd is not None: + os.close(fd) + except BlockingIOError: + if fd is not None: + os.close(fd) + self._thread_lock.release() + return False + except Exception: + if fd is not None: + os.close(fd) + self._thread_lock.release() + raise + return True + + def release(self) -> None: + try: + if self._fd is not None: + try: + import fcntl + fcntl.flock(self._fd, fcntl.LOCK_UN) + except (ImportError, OSError): + pass + finally: + try: + os.close(self._fd) + except OSError: + pass + self._fd = None + finally: + self._thread_lock.release() + + def __enter__(self) -> "_InterProcessSettingsFileLock": + self.acquire() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + self.release() + + +# Shared inter-process lock protecting all reads/writes to SETTINGS_FILE. # Both settings.py and email_config.py import this lock so that concurrent -# writes from different code paths (scheduler, HTTP handlers) are serialised. -_settings_file_lock = threading.Lock() +# writes from different code paths (scheduler, HTTP handlers, workers) are +# serialised across all Gunicorn worker processes. +_settings_lock_file = os.path.join( + os.fspath(SETTINGS_FILE.parent), + f"{SETTINGS_FILE.name}.lock", +) +_settings_file_lock = _InterProcessSettingsFileLock(_settings_lock_file) logger = logging.getLogger(__name__) From c14c3428293a5878308d38e39aae7c72100929b1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:32:28 +0200 Subject: [PATCH 4/6] Fix lock path resolution, i18n gaps, and entrypoint symlink safety (#52) * Initial plan * Fix lock path at acquire-time, i18n hardcoded strings, entrypoint symlink safety Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dvir001 <39403717+dvir001@users.noreply.github.com> --- deploy/entrypoint.sh | 2 +- simple_org_chart/settings.py | 42 ++++++++++++++++++++++++++++-------- static/configure.js | 31 +++++++++++++------------- static/locales/en-US.json | 18 ++++++++++++++++ tests/test_email_schedule.py | 4 ++++ 5 files changed, 72 insertions(+), 25 deletions(-) diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index b709e67..44ee421 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -11,7 +11,7 @@ for dir in /app/data /app/config /app/repositories; do if [ -d "$dir" ]; then current_owner=$(stat -c '%u:%g' "$dir" 2>/dev/null || echo "") if [ -z "$current_owner" ] || [ "$current_owner" != "$APP_UID:$APP_GID" ]; then - chown -R app:app "$dir" 2>/dev/null || true + chown -R --no-dereference app:app "$dir" 2>/dev/null || true fi fi done diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py index 2913637..3e5258c 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -9,7 +9,7 @@ import re import tempfile import threading -from typing import Any, Dict, Iterable, Set +from typing import Any, Callable, Dict, Iterable, Set, Union from .config import SETTINGS_FILE @@ -24,8 +24,21 @@ class _InterProcessSettingsFileLock: falls back gracefully to thread-only locking. """ - def __init__(self, lock_file_path: str) -> None: - self._lock_file_path = lock_file_path + def __init__( + self, + lock_file_path: Union[str, bytes, "os.PathLike[str]", Callable[[], Union[str, bytes, "os.PathLike[str]"]]], + ) -> None: + # Accept either a static path (str/bytes/Path) or a zero-argument + # callable that returns the path at acquire-time. The callable form + # lets callers that re-bind the module-level SETTINGS_FILE (e.g. in + # tests via monkeypatch) have the lock always protect the file that is + # actually being accessed rather than the path that was current at + # import time. + if callable(lock_file_path): + self._get_lock_file_path = lock_file_path + else: + _static = lock_file_path + self._get_lock_file_path = lambda: _static self._thread_lock = threading.Lock() self._fd: int | None = None @@ -36,7 +49,8 @@ def acquire(self, blocking: bool = True) -> bool: fd = None try: import fcntl - fd = os.open(self._lock_file_path, os.O_CREAT | os.O_RDWR, 0o600) + lock_file_path = self._get_lock_file_path() + fd = os.open(lock_file_path, os.O_CREAT | os.O_RDWR, 0o600) flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) fcntl.flock(fd, flags) self._fd = fd @@ -85,11 +99,21 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # Both settings.py and email_config.py import this lock so that concurrent # writes from different code paths (scheduler, HTTP handlers, workers) are # serialised across all Gunicorn worker processes. -_settings_lock_file = os.path.join( - os.fspath(SETTINGS_FILE.parent), - f"{SETTINGS_FILE.name}.lock", -) -_settings_file_lock = _InterProcessSettingsFileLock(_settings_lock_file) +# +# The factory callable reads SETTINGS_FILE from this module's namespace at +# acquire-time rather than computing the path once at import time. This +# allows test-time monkeypatching of ``simple_org_chart.settings.SETTINGS_FILE`` +# to take effect so the lock file is always co-located with the settings file +# actually being accessed. +def _settings_lock_file_factory() -> str: + # Accessing the module-level name SETTINGS_FILE here (rather than via + # globals()) is sufficient: Python resolves global names at call-time via + # LOAD_GLOBAL, so monkeypatching ``simple_org_chart.settings.SETTINGS_FILE`` + # in tests will be reflected when this factory is invoked. + return os.path.join(os.fspath(SETTINGS_FILE.parent), f"{SETTINGS_FILE.name}.lock") + + +_settings_file_lock = _InterProcessSettingsFileLock(_settings_lock_file_factory) logger = logging.getLogger(__name__) diff --git a/static/configure.js b/static/configure.js index 0c2172b..a22896a 100644 --- a/static/configure.js +++ b/static/configure.js @@ -2363,10 +2363,10 @@ async function _initUserScannerConfigUI(isEnabled) { const latest = await statusResp.json(); if (latest.installed) { if (installRow) installRow.style.display = 'none'; - const ver = latest.version || 'unknown'; + const ver = latest.version || resolveTranslation('configure.userScanner.status.unknownVersion', 'unknown'); statusEl.innerHTML = ''; const versionText = document.createElement('span'); - versionText.textContent = `Installed — v${ver}`; + versionText.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', 'Installed — v') + ver; statusEl.appendChild(versionText); const sep = document.createTextNode(' · '); statusEl.appendChild(sep); @@ -2374,7 +2374,7 @@ async function _initUserScannerConfigUI(isEnabled) { repoLink.href = 'https://github.com/kaifcodec/user-scanner'; repoLink.target = '_blank'; repoLink.rel = 'noopener noreferrer'; - repoLink.textContent = 'GitHub repo ↗'; + repoLink.textContent = resolveTranslation('configure.userScanner.status.repoLink', 'GitHub repo ↗'); repoLink.style.fontSize = 'inherit'; statusEl.appendChild(repoLink); if (updateRow) updateRow.style.display = 'flex'; @@ -2395,10 +2395,10 @@ async function _initUserScannerConfigUI(isEnabled) { } } else { if (installRow) installRow.style.display = 'none'; - const ver = data.version || 'unknown'; + const ver = data.version || resolveTranslation('configure.userScanner.status.unknownVersion', 'unknown'); statusEl.innerHTML = ''; const versionText = document.createElement('span'); - versionText.textContent = `Installed — v${ver}`; + versionText.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', 'Installed — v') + ver; statusEl.appendChild(versionText); const sep = document.createTextNode(' · '); statusEl.appendChild(sep); @@ -2406,7 +2406,7 @@ async function _initUserScannerConfigUI(isEnabled) { repoLink.href = 'https://github.com/kaifcodec/user-scanner'; repoLink.target = '_blank'; repoLink.rel = 'noopener noreferrer'; - repoLink.textContent = 'GitHub repo ↗'; + repoLink.textContent = resolveTranslation('configure.userScanner.status.repoLink', 'GitHub repo ↗'); repoLink.style.fontSize = 'inherit'; statusEl.appendChild(repoLink); if (updateRow) updateRow.style.display = 'flex'; @@ -2417,19 +2417,20 @@ async function _initUserScannerConfigUI(isEnabled) { if (checkBtn) { checkBtn.addEventListener('click', async () => { checkBtn.disabled = true; - if (updateStatusEl) updateStatusEl.textContent = 'Checking…'; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.checking', 'Checking…'); if (applyBtn) applyBtn.style.display = 'none'; try { const resp = await fetch(`${window.location.origin}/api/user-scanner/check-update`, { credentials: 'include' }); const info = await resp.json(); if (info.updateAvailable) { - if (updateStatusEl) updateStatusEl.textContent = `Update available: v${info.currentVersion} → v${info.latestVersion}`; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.availablePrefix', 'Update available: v') + info.currentVersion + resolveTranslation('configure.userScanner.update.availableArrow', ' → v') + info.latestVersion; if (applyBtn) applyBtn.style.display = ''; } else { - if (updateStatusEl) updateStatusEl.textContent = `Up to date (v${info.currentVersion || info.latestVersion || 'unknown'})`; + const ver = info.currentVersion || info.latestVersion || resolveTranslation('configure.userScanner.update.unknownVersion', 'unknown'); + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.upToDatePrefix', 'Up to date (v') + ver + resolveTranslation('configure.userScanner.update.upToDateSuffix', ')'); } } catch (err) { - if (updateStatusEl) updateStatusEl.textContent = 'Failed to check for updates.'; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.checkFailed', 'Failed to check for updates.'); } finally { checkBtn.disabled = false; } @@ -2440,7 +2441,7 @@ async function _initUserScannerConfigUI(isEnabled) { if (applyBtn) { applyBtn.addEventListener('click', async () => { applyBtn.disabled = true; - if (updateStatusEl) updateStatusEl.textContent = 'Updating…'; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.updating', 'Updating…'); try { const resp = await fetch(`${window.location.origin}/api/user-scanner/update`, { method: 'POST', @@ -2448,17 +2449,17 @@ async function _initUserScannerConfigUI(isEnabled) { }); const result = await resp.json(); if (result.success) { - if (updateStatusEl) updateStatusEl.textContent = `Updated to v${result.version}`; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.updatedPrefix', 'Updated to v') + result.version; if (statusEl) { const verSpan = statusEl.querySelector('span'); - if (verSpan) verSpan.textContent = `Installed — v${result.version}`; + if (verSpan) verSpan.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', 'Installed — v') + result.version; } applyBtn.style.display = 'none'; } else { - if (updateStatusEl) updateStatusEl.textContent = result.error || 'Update failed.'; + if (updateStatusEl) updateStatusEl.textContent = result.error || resolveTranslation('configure.userScanner.update.failed', 'Update failed.'); } } catch (err) { - if (updateStatusEl) updateStatusEl.textContent = 'Update failed: ' + err.message; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.failedPrefix', 'Update failed: ') + err.message; } finally { applyBtn.disabled = false; } diff --git a/static/locales/en-US.json b/static/locales/en-US.json index 3524ff1..dd0c561 100644 --- a/static/locales/en-US.json +++ b/static/locales/en-US.json @@ -214,6 +214,24 @@ "failed": "Installation failed.", "notInstalled": "Not installed — download to enable the scanner.", "downloadFailed": "Download failed: " + }, + "status": { + "installedPrefix": "Installed — v", + "repoLink": "GitHub repo ↗", + "unknownVersion": "unknown" + }, + "update": { + "checking": "Checking…", + "availablePrefix": "Update available: v", + "availableArrow": " → v", + "upToDatePrefix": "Up to date (v", + "upToDateSuffix": ")", + "unknownVersion": "unknown", + "checkFailed": "Failed to check for updates.", + "updating": "Updating…", + "updatedPrefix": "Updated to v", + "failed": "Update failed.", + "failedPrefix": "Update failed: " } }, "presence": { diff --git a/tests/test_email_schedule.py b/tests/test_email_schedule.py index 598a815..3706287 100644 --- a/tests/test_email_schedule.py +++ b/tests/test_email_schedule.py @@ -24,6 +24,7 @@ should_send_email_now, ) import simple_org_chart.email_config as _email_config_mod +import simple_org_chart.settings as _settings_mod # --------------------------------------------------------------------------- @@ -252,6 +253,7 @@ class TestSavePreservesLastSent: def test_last_sent_preserved_on_save(self, tmp_path, monkeypatch): config_file = tmp_path / "app_settings.json" monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) + monkeypatch.setattr(_settings_mod, "SETTINGS_FILE", config_file) # Simulate a previous email send initial = DEFAULT_EMAIL_CONFIG.copy() @@ -268,6 +270,7 @@ def test_last_sent_preserved_on_save(self, tmp_path, monkeypatch): def test_last_sent_overwritten_when_explicitly_provided(self, tmp_path, monkeypatch): config_file = tmp_path / "app_settings.json" monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) + monkeypatch.setattr(_settings_mod, "SETTINGS_FILE", config_file) initial = DEFAULT_EMAIL_CONFIG.copy() initial["lastSent"] = "2026-03-01T12:00:00+00:00" @@ -282,6 +285,7 @@ def test_last_sent_overwritten_when_explicitly_provided(self, tmp_path, monkeypa def test_last_sent_none_when_no_prior_config(self, tmp_path, monkeypatch): config_file = tmp_path / "app_settings.json" monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) + monkeypatch.setattr(_settings_mod, "SETTINGS_FILE", config_file) save_email_config({"enabled": True, "frequency": "weekly"}) From 78933148f390142c729b1841cf988392c5778687 Mon Sep 17 00:00:00 2001 From: Dvir <39403717+dvir001@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:42:38 +0200 Subject: [PATCH 5/6] Update simple_org_chart/app_main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- simple_org_chart/app_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py index 3bc002b..58232f6 100644 --- a/simple_org_chart/app_main.py +++ b/simple_org_chart/app_main.py @@ -1177,7 +1177,7 @@ def import_settings(): file_size = uploaded.tell() uploaded.seek(0) if file_size > MAX_FILE_SIZE: - return jsonify({'error': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024 * 1024)}MB'}), 413 + return jsonify({'error': f'File too large. Maximum size: {MAX_FILE_SIZE // (1024 * 1024)}MB'}), 400 try: raw = uploaded.read() From e44e7934448c53bc4748309a7a0c3ab9e3f7ab85 Mon Sep 17 00:00:00 2001 From: Dvir <39403717+dvir001@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:42:47 +0200 Subject: [PATCH 6/6] Update simple_org_chart/settings.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- simple_org_chart/settings.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py index 3e5258c..229525c 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -287,8 +287,20 @@ def save_settings(settings: Dict[str, Any]) -> bool: loaded = json.load(handle) if isinstance(loaded, dict): existing = loaded - except Exception: - pass + except Exception as error: # noqa: BLE001 - log and continue with empty + logger.warning( + "Failed to load existing settings from %s; treating as empty. " + "Backing up corrupt file if possible. Error: %s", + SETTINGS_FILE, + error, + ) + # Best-effort backup of the unreadable/corrupt settings file + with contextlib.suppress(Exception): + backup_path = SETTINGS_FILE.with_suffix( + SETTINGS_FILE.suffix + ".corrupt" + ) + if not backup_path.exists(): + os.replace(SETTINGS_FILE, backup_path) existing.update(persisted)