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/Dockerfile b/Dockerfile index e5f7747..00ed105 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 775 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..44ee421 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +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 + current_owner=$(stat -c '%u:%g' "$dir" 2>/dev/null || echo "") + if [ -z "$current_owner" ] || [ "$current_owner" != "$APP_UID:$APP_GID" ]; then + chown -R --no-dereference app:app "$dir" 2>/dev/null || true + fi + 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..58232f6 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,89 @@ 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 and non-default keys + settings_public = {k: v for k, v in settings.items() if k in DEFAULT_SETTINGS} + merged = {} + 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( + 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 + + # 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'}), 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 + + # 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: + 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..b4019ab 100644 --- a/simple_org_chart/email_config.py +++ b/simple_org_chart/email_config.py @@ -2,19 +2,20 @@ 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 DATA_DIR +from .config import SETTINGS_FILE +from .settings import _settings_file_lock 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 +28,15 @@ "lastSent": None, # ISO timestamp of last sent email } +# 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) @@ -52,64 +62,94 @@ 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) - 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() 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() - - # Merge with defaults + """Save email configuration into app_settings.json.""" + SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + + # 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: - 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" - - logger.info("Saving email configuration to: %s", EMAIL_CONFIG_FILE) + + 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_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: - 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 +218,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 +268,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..229525c 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -2,14 +2,119 @@ from __future__ import annotations +import contextlib import json import logging import os import re -from typing import Any, Dict, Iterable, Set +import tempfile +import threading +from typing import Any, Callable, Dict, Iterable, Set, Union from .config import 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: 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 + + def acquire(self, blocking: bool = True) -> bool: + acquired = self._thread_lock.acquire(blocking) + if not acquired: + return False + fd = None + try: + import fcntl + 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 + 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, workers) are +# serialised across all Gunicorn worker processes. +# +# 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__) DEFAULT_SETTINGS: Dict[str, Any] = { @@ -173,8 +278,44 @@ def save_settings(settings: Dict[str, Any]) -> bool: 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) + 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 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) + + # 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 @@ -277,6 +418,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 2a1928c..362e24f 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" # --------------------------------------------------------------------------- @@ -116,8 +118,28 @@ def _ensure_on_path() -> None: sys.path.insert(0, repo_str) -def get_latest_pypi_version() -> Optional[str]: - """Query PyPI for the latest release version of user-scanner.""" +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. + """ + # 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", @@ -131,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 2dabfc7..a22896a 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,13 +2337,68 @@ 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 = resolveTranslation('configure.userScanner.install.notInstalled', '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 = resolveTranslation('configure.userScanner.install.downloading', '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 = ''; + // 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 || resolveTranslation('configure.userScanner.status.unknownVersion', 'unknown'); + statusEl.innerHTML = ''; + const versionText = document.createElement('span'); + versionText.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', '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 = resolveTranslation('configure.userScanner.status.repoLink', '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 || resolveTranslation('configure.userScanner.install.failed', 'Installation failed.'); + installBtn.disabled = false; + } + } catch (err) { + if (installStatusEl) installStatusEl.textContent = resolveTranslation('configure.userScanner.install.downloadFailed', 'Download failed: ') + err.message; + installBtn.disabled = false; + } + }; + } } else { - const ver = data.version || 'unknown'; + if (installRow) installRow.style.display = 'none'; + 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); @@ -2293,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'; @@ -2304,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; } @@ -2327,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', @@ -2335,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 bef9aaf..dd0c561 100644 --- a/static/locales/en-US.json +++ b/static/locales/en-US.json @@ -1,704 +1,752 @@ { - "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 will be downloaded automatically from PyPI when enabled and kept up to date.", - "toggleLabel": "Enable User Scanner" - } - }, - "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" - } - }, - "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: " + }, + "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": { + "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)" + } + } } 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 @@