diff --git a/.dockerignore b/.dockerignore
index 18050e3..15e5f64 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -28,6 +28,7 @@ coverage.xml
.mypy_cache
.pytest_cache
.hypothesis
+.tests/
# OS
.DS_Store
diff --git a/.env.template b/.env.template
index 87c3d71..68693f1 100644
--- a/.env.template
+++ b/.env.template
@@ -10,7 +10,24 @@ AZURE_CLIENT_ID=your-client-id-here
# Your Azure AD App Registration Client Secret
AZURE_CLIENT_SECRET=your-client-secret-here
+# Optional Microsoft Entra sign-in (uses a separate App Registration)
+# Use "simple" for the password-based admin flow or "oidc" for Microsoft sign-in.
+# For OIDC, set "Assignment required?" to Yes on the Enterprise Application.
+AUTH_TYPE=simple
+OIDC_TENANT_ID=your-tenant-id-here
+OIDC_CLIENT_ID=your-auth-app-client-id-here
+OIDC_CLIENT_SECRET=your-auth-app-client-secret-here
+
+# Access is denied unless the user belongs to one of these groups.
+OIDC_READER_GROUP_ID=your-org-chart-reader-group-object-id
+OIDC_PRIVILEGED_GROUP_ID=your-sync-reports-group-object-id
+OIDC_ADMIN_GROUP_ID=your-full-admin-group-object-id
+
+# Optional override. Must exactly match the auth app's Web redirect URI.
+# OIDC_REDIRECT_URI=https://org-chart.example.com/auth/callback
+
# Security Configuration
+# Required only when AUTH_TYPE=simple
ADMIN_PASSWORD=your-admin-password-here
SECRET_KEY=generate-a-64-character-random-string
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index 7ba89c1..0000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# GitHub Copilot Instructions
-
-## Repository context
-- **Project**: SimpleOrgChart – Flask backend with static front-end (vanilla JS + D3).
-- **Primary entrypoints**:
- - Backend: `app.py`
- - Front-end bundles: `static/app.js`, `static/configureme.js`, `static/reports.js`, etc.
- - Templates: `templates/*.html`
-- **Data**: Cached JSON files under the `data/` directory (e.g., `employee_data.json`, report caches).
-
-## Coding conventions
-- Prefer modern JavaScript (`const`/`let`, template literals, async/await) without introducing frameworks.
-- Maintain separation of concerns: keep inline scripts/styles out of templates; use the existing static files.
-- Follow existing logging patterns (`logger.info`, `logger.error`) and error handling style in `app.py`.
-- Keep CSS variables and shared styles in `static/styles.css` unless a page-specific stylesheet exists.
-- Use i18n keys for user-facing text. Strings live in `static/locales/en-US.json`.
-
-## Workflow expectations
-1. **Plan**: Review related files before making changes; reuse helpers (e.g., `fetch_all_employees`, report loaders).
-2. **Implement**: Apply minimal diffs using the preferred tools (`apply_patch`, `insert_edit_into_file`). Avoid rewriting unaffected code.
-3. **Validate**: Run targeted checks when possible (e.g., linting, unit tests). If tooling is absent, note that in your summary.
-4. **Document**: Update README or inline comments when behavior changes. Internationalized text requires locale updates.
-
-## Feature guidelines
-- **Reports**: When adding a report, create matching API routes, cache loaders, export endpoints, front-end configs, template options, and locale strings.
-- **Translations**: Guard against untranslated flashes by toggling the `i18n-loading` class via JS once translations load.
-- **Graph API**: Reuse existing helpers; ensure required permissions are documented (`User.Read.All`, `LicenseAssignment.Read.All`).
-- **Caching**: Persist report data in `data/*.json` with graceful fallbacks if files are missing or unreadable.
-
-## Pull request readiness checklist
-- [ ] All relevant caches/readers updated.
-- [ ] Locale strings added/updated for new UI text.
-- [ ] Front-end selectors and summaries reflect new features.
-- [ ] Backend routes include authentication decorators and error handling.
-- [ ] Tests or manual validation steps recorded in the summary.
-
-## Additional notes
-- Do not introduce new dependencies without updating `requirements.txt` (backend) or documenting browser requirements.
-- Keep responses concise and focused—summaries should mention actions, validation, and outstanding work.
-- If unable to run automated checks (common in this repo), state that explicitly during the handoff.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e756b41..0829678 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -33,4 +33,4 @@ jobs:
pip install pytest
- name: Run tests
- run: python -m pytest tests/ -v --tb=short
+ run: python -m pytest .tests/ -v --tb=short
diff --git a/tests/conftest.py b/.tests/conftest.py
similarity index 91%
rename from tests/conftest.py
rename to .tests/conftest.py
index 99cfcda..3cd8714 100644
--- a/tests/conftest.py
+++ b/.tests/conftest.py
@@ -4,6 +4,7 @@
import json
import os
+import shutil
import sys
import tempfile
from pathlib import Path
@@ -20,6 +21,14 @@
os.environ.setdefault("ADMIN_PASSWORD", "test-password-for-ci-only")
os.environ.setdefault("SECRET_KEY", "test-secret-key")
+_TEST_RUNTIME_DIR = Path(tempfile.mkdtemp(prefix="simple-org-chart-tests-"))
+os.environ["SIMPLE_ORG_CHART_DATA_DIR"] = str(_TEST_RUNTIME_DIR / "data")
+os.environ["SESSION_FILE_DIR"] = str(_TEST_RUNTIME_DIR / "flask_session")
+
+
+def pytest_sessionfinish(session, exitstatus):
+ shutil.rmtree(_TEST_RUNTIME_DIR, ignore_errors=True)
+
@pytest.fixture()
def sample_employees() -> List[Dict[str, Any]]:
diff --git a/.tests/test_auth.py b/.tests/test_auth.py
new file mode 100644
index 0000000..326fede
--- /dev/null
+++ b/.tests/test_auth.py
@@ -0,0 +1,314 @@
+"""Tests for simple_org_chart.auth – decorators & path sanitisation."""
+
+from __future__ import annotations
+
+from io import BytesIO
+import json
+import pytest
+from flask import Flask
+from unittest.mock import patch
+from openpyxl import load_workbook
+from simple_org_chart.auth import (
+ privileged_login_required,
+ require_auth,
+ require_privileged,
+ sanitize_next_path,
+)
+from simple_org_chart.app_main import app as org_chart_app
+
+
+# ---------------------------------------------------------------------------
+# sanitize_next_path
+# ---------------------------------------------------------------------------
+
+
+class TestSanitizeNextPath:
+ """Validate redirect-path sanitisation against open-redirect attacks."""
+
+ @pytest.mark.parametrize(
+ "raw, expected",
+ [
+ (None, ""),
+ ("", ""),
+ (" ", ""),
+ ("configure", "configure"),
+ ("/configure", "configure"),
+ ("reports", "reports"),
+ ("/reports", "reports"),
+ ],
+ )
+ def test_valid_paths(self, raw, expected):
+ assert sanitize_next_path(raw) == expected
+
+ @pytest.mark.parametrize(
+ "raw",
+ [
+ "http://evil.com",
+ "https://evil.com",
+ "//evil.com",
+ "http://evil.com/configure",
+ "/path with spaces",
+ "/path?query=1",
+ "/path#fragment",
+ "/",
+ ],
+ )
+ def test_malicious_paths_rejected(self, raw):
+ assert sanitize_next_path(raw) == ""
+
+
+@pytest.fixture()
+def role_client():
+ app = Flask(__name__)
+ app.config.update(SECRET_KEY="test", TESTING=True)
+
+ @app.get("/login")
+ def login():
+ return "login"
+
+ @app.get("/privileged-api")
+ @require_privileged
+ def privileged_api():
+ return "ok"
+
+ @app.get("/admin-api")
+ @require_auth
+ def admin_api():
+ return "ok"
+
+ @app.get("/reports")
+ @privileged_login_required
+ def reports():
+ return "ok"
+
+ return app.test_client()
+
+
+def _set_role(client, role=None):
+ with client.session_transaction() as user_session:
+ user_session["authenticated"] = True
+ if role:
+ user_session["role"] = role
+
+
+class TestRoleAuthorization:
+ def test_unauthenticated_api_returns_401(self, role_client):
+ assert role_client.get("/privileged-api").status_code == 401
+
+ def test_reader_cannot_access_privileged_or_admin_api(self, role_client):
+ _set_role(role_client, "reader")
+ assert role_client.get("/privileged-api").status_code == 403
+ assert role_client.get("/admin-api").status_code == 403
+
+ def test_privileged_role_cannot_access_admin_api(self, role_client):
+ _set_role(role_client, "privileged")
+ assert role_client.get("/privileged-api").status_code == 200
+ assert role_client.get("/admin-api").status_code == 403
+
+ def test_admin_role_can_access_all_tiers(self, role_client):
+ _set_role(role_client, "admin")
+ assert role_client.get("/privileged-api").status_code == 200
+ assert role_client.get("/admin-api").status_code == 200
+
+ def test_legacy_authenticated_session_is_admin(self, role_client):
+ _set_role(role_client)
+ assert role_client.get("/admin-api").status_code == 200
+
+ def test_reader_gets_forbidden_report_page(self, role_client):
+ _set_role(role_client, "reader")
+ assert role_client.get("/reports").status_code == 403
+
+
+@pytest.fixture()
+def app_client():
+ org_chart_app.config.update(TESTING=True)
+ return org_chart_app.test_client()
+
+
+class TestApplicationRouteTiers:
+ def test_reader_cannot_open_reports_or_configure(self, app_client):
+ _set_role(app_client, "reader")
+ assert app_client.get("/reports").status_code == 403
+ assert app_client.get("/configure").status_code == 403
+
+ def test_privileged_user_can_open_reports_but_not_configure(self, app_client):
+ _set_role(app_client, "privileged")
+ assert app_client.get("/reports").status_code == 200
+ assert app_client.get("/configure").status_code == 403
+
+ def test_admin_can_open_configure(self, app_client):
+ _set_role(app_client, "admin")
+ assert app_client.get("/configure").status_code == 200
+
+ def test_auth_check_returns_capabilities(self, app_client):
+ _set_role(app_client, "privileged")
+ response = app_client.get("/api/auth-check")
+ assert response.status_code == 200
+ assert response.get_json() == {
+ "authenticated": True,
+ "authType": "simple",
+ "canAccessReports": True,
+ "canAccessRestrictedXlsx": True,
+ "canAdminister": False,
+ "canSync": True,
+ "role": "privileged",
+ }
+
+ def test_graph_capabilities_uses_persisted_sync_data(self, app_client, tmp_path):
+ capabilities_file = tmp_path / "graph_capabilities.json"
+ capabilities_file.write_text(
+ json.dumps({"mailbox_settings_read": True}),
+ encoding="utf-8",
+ )
+ _set_role(app_client, "privileged")
+
+ with patch(
+ "simple_org_chart.app_main.GRAPH_CAPABILITIES_FILE",
+ str(capabilities_file),
+ ):
+ response = app_client.get("/api/graph-capabilities")
+
+ assert response.status_code == 200
+ assert response.get_json() == {
+ "available": True,
+ "mailbox_settings_read": True,
+ }
+
+ def test_oidc_auth_check_returns_current_user_identity(self, app_client):
+ with app_client.session_transaction() as user_session:
+ user_session["authenticated"] = True
+ user_session["role"] = "reader"
+ user_session["oidc_session_version"] = 3
+ user_session["oidc_user_id"] = "graph-user-id"
+ user_session["oidc_user_email"] = "user@example.com"
+
+ with patch("simple_org_chart.app_main.AUTH_TYPE", "oidc"):
+ response = app_client.get("/api/auth-check")
+
+ assert response.status_code == 200
+ assert response.get_json()["user"] == {
+ "id": "graph-user-id",
+ "email": "user@example.com",
+ }
+
+ def test_privileged_permissions_can_be_disabled_independently(self, app_client):
+ _set_role(app_client, "privileged")
+ settings = {
+ "oidcPrivilegedPermissions": {
+ "reports": False,
+ "sync": False,
+ "restrictedXlsx": True,
+ }
+ }
+ with patch("simple_org_chart.app_main.load_settings", return_value=settings):
+ auth_response = app_client.get("/api/auth-check")
+ assert auth_response.get_json()["canAccessReports"] is False
+ assert auth_response.get_json()["canSync"] is False
+ assert auth_response.get_json()["canAccessRestrictedXlsx"] is True
+ assert app_client.get("/reports").status_code == 403
+ assert app_client.post("/api/update-now").status_code == 403
+
+ def test_admin_permissions_cannot_be_disabled(self, app_client):
+ _set_role(app_client, "admin")
+ settings = {
+ "oidcPrivilegedPermissions": {
+ "reports": False,
+ "sync": False,
+ "restrictedXlsx": False,
+ }
+ }
+ with patch("simple_org_chart.app_main.load_settings", return_value=settings):
+ response = app_client.get("/api/auth-check")
+ assert response.get_json()["canAccessReports"] is True
+ assert response.get_json()["canSync"] is True
+ assert response.get_json()["canAccessRestrictedXlsx"] is True
+
+ def test_restricted_xlsx_columns_follow_permission(self, app_client, tmp_path):
+ data_file = tmp_path / "employee_data.json"
+ data_file.write_text(
+ json.dumps({"name": "Test User", "hireDate": "2024-01-01", "children": []}),
+ encoding="utf-8",
+ )
+ settings = {
+ "hideDisabledUsers": False,
+ "hideGuestUsers": False,
+ "hideNoTitle": False,
+ "ignoredDepartments": "",
+ "exportXlsxColumns": {
+ "name": "show",
+ "hireDate": "admin",
+ },
+ "oidcPrivilegedPermissions": {"restrictedXlsx": False},
+ }
+ _set_role(app_client, "privileged")
+
+ with (
+ patch("simple_org_chart.app_main.DATA_FILE", str(data_file)),
+ patch("simple_org_chart.app_main.load_settings", return_value=settings),
+ ):
+ response = app_client.get("/api/export-xlsx")
+
+ workbook = load_workbook(BytesIO(response.data))
+ headers = [cell.value for cell in workbook.active[1]]
+ assert "Name" in headers
+ assert "Hire Date" not in headers
+
+ def test_reader_cannot_trigger_sync(self, app_client):
+ _set_role(app_client, "reader")
+ assert app_client.post("/api/update-now").status_code == 403
+
+ def test_privileged_user_cannot_export_admin_settings(self, app_client):
+ _set_role(app_client, "privileged")
+ assert app_client.get("/api/settings/export").status_code == 403
+
+ def test_privileged_user_cannot_mutate_admin_settings(self, app_client):
+ _set_role(app_client, "privileged")
+ assert app_client.post("/api/settings", json={}).status_code == 403
+
+ def test_oidc_callback_denies_user_outside_all_access_groups(self, app_client):
+ with app_client.session_transaction() as user_session:
+ user_session["oidc_flow"] = {"state": "test-state"}
+
+ with (
+ patch("simple_org_chart.app_main.AUTH_TYPE", "oidc"),
+ patch(
+ "simple_org_chart.app_main.complete_login",
+ return_value={"access_token": "token"},
+ ),
+ patch(
+ "simple_org_chart.app_main.fetch_user_and_groups",
+ return_value=({"userPrincipalName": "user@example.com"}, set()),
+ ),
+ ):
+ response = app_client.get("/auth/callback?state=test-state&code=code")
+
+ assert response.status_code == 403
+ with app_client.session_transaction() as user_session:
+ assert "authenticated" not in user_session
+
+ def test_oidc_mode_invalidates_session_from_old_access_rules(self, app_client):
+ _set_role(app_client, "reader")
+
+ with patch("simple_org_chart.app_main.AUTH_TYPE", "oidc"):
+ response = app_client.get("/")
+
+ assert response.status_code == 302
+ assert response.headers["Location"].endswith("/login")
+ with app_client.session_transaction() as user_session:
+ assert "authenticated" not in user_session
+
+ def test_oidc_config_page_shows_groups_without_logout(self, app_client):
+ with app_client.session_transaction() as user_session:
+ user_session["authenticated"] = True
+ user_session["role"] = "admin"
+ user_session["oidc_session_version"] = 3
+
+ with patch("simple_org_chart.app_main.AUTH_TYPE", "oidc"):
+ response = app_client.get("/configure")
+
+ page = response.get_data(as_text=True)
+ assert response.status_code == 200
+ assert 'id="oidcReaderGroupId"' in page
+ assert 'id="oidcPrivilegedGroupId"' in page
+ assert 'id="oidcAdminGroupId"' in page
+ assert 'data-config-action="logout"' not in page
diff --git a/tests/test_config.py b/.tests/test_config.py
similarity index 89%
rename from tests/test_config.py
rename to .tests/test_config.py
index 8cf03ce..00379aa 100644
--- a/tests/test_config.py
+++ b/.tests/test_config.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import os
from pathlib import Path
from simple_org_chart.config import (
@@ -21,8 +22,11 @@
class TestConfigPaths:
"""Verify that config-level path constants are consistent."""
- def test_data_dir_under_base(self):
- assert DATA_DIR.parent == BASE_DIR
+ def test_data_dir_uses_configured_path(self):
+ expected = Path(
+ os.environ.get("SIMPLE_ORG_CHART_DATA_DIR", BASE_DIR / "data")
+ ).resolve()
+ assert DATA_DIR == expected
def test_static_dir_under_base(self):
assert STATIC_DIR.parent == BASE_DIR
diff --git a/tests/test_email_schedule.py b/.tests/test_email_schedule.py
similarity index 100%
rename from tests/test_email_schedule.py
rename to .tests/test_email_schedule.py
diff --git a/tests/test_exports.py b/.tests/test_exports.py
similarity index 100%
rename from tests/test_exports.py
rename to .tests/test_exports.py
diff --git a/tests/test_hierarchy.py b/.tests/test_hierarchy.py
similarity index 100%
rename from tests/test_hierarchy.py
rename to .tests/test_hierarchy.py
diff --git a/tests/test_i18n.py b/.tests/test_i18n.py
similarity index 100%
rename from tests/test_i18n.py
rename to .tests/test_i18n.py
diff --git a/.tests/test_oidc_auth.py b/.tests/test_oidc_auth.py
new file mode 100644
index 0000000..d99451f
--- /dev/null
+++ b/.tests/test_oidc_auth.py
@@ -0,0 +1,72 @@
+"""Tests for Microsoft Entra authentication helpers."""
+
+from unittest.mock import Mock, patch
+
+from simple_org_chart.oidc_auth import OidcConfig, fetch_user_and_groups, resolve_role
+
+
+def _config():
+ return OidcConfig(
+ tenant_id="tenant",
+ client_id="client",
+ client_secret="secret",
+ reader_group_id="reader-group",
+ privileged_group_id="privileged-group",
+ admin_group_id="admin-group",
+ )
+
+
+def test_resolve_role_rejects_user_outside_access_groups():
+ assert resolve_role(_config(), {"unrelated-group"}) is None
+
+
+def test_resolve_role_matches_reader_group():
+ assert resolve_role(_config(), {"reader-group"}) == "reader"
+
+
+def test_resolve_role_matches_privileged_group_case_insensitively():
+ assert resolve_role(_config(), {"PRIVILEGED-GROUP"}) == "privileged"
+
+
+def test_admin_group_takes_precedence():
+ assert resolve_role(_config(), {"privileged-group", "admin-group"}) == "admin"
+
+
+def test_resolve_role_ignores_config_whitespace():
+ config = OidcConfig(
+ tenant_id="tenant",
+ client_id="client",
+ client_secret="secret",
+ reader_group_id=" reader-group ",
+ privileged_group_id=" privileged-group ",
+ admin_group_id=" admin-group ",
+ )
+ assert resolve_role(config, {"privileged-group"}) == "privileged"
+
+
+@patch("simple_org_chart.oidc_auth.requests.post")
+@patch("simple_org_chart.oidc_auth.requests.get")
+def test_fetch_user_and_groups_checks_only_configured_groups(mock_get, mock_post):
+ user_response = Mock()
+ user_response.json.return_value = {"id": "user", "displayName": "Test User"}
+ mock_get.return_value = user_response
+ group_response = Mock()
+ group_response.json.return_value = {"value": ["PRIVILEGED-GROUP"]}
+ mock_post.return_value = group_response
+
+ user, group_ids = fetch_user_and_groups(
+ "token",
+ {" privileged-group ", "admin-group"},
+ )
+
+ assert user["id"] == "user"
+ assert group_ids == {"privileged-group"}
+ mock_post.assert_called_once_with(
+ "https://graph.microsoft.com/v1.0/me/checkMemberGroups",
+ headers={
+ "Authorization": "Bearer token",
+ "Content-Type": "application/json",
+ },
+ json={"groupIds": ["admin-group", "privileged-group"]},
+ timeout=15,
+ )
\ No newline at end of file
diff --git a/tests/test_report_configs.py b/.tests/test_report_configs.py
similarity index 100%
rename from tests/test_report_configs.py
rename to .tests/test_report_configs.py
diff --git a/tests/test_reports.py b/.tests/test_reports.py
similarity index 100%
rename from tests/test_reports.py
rename to .tests/test_reports.py
diff --git a/tests/test_settings.py b/.tests/test_settings.py
similarity index 100%
rename from tests/test_settings.py
rename to .tests/test_settings.py
diff --git a/tests/test_templates.py b/.tests/test_templates.py
similarity index 89%
rename from tests/test_templates.py
rename to .tests/test_templates.py
index d4b6dbf..78594ca 100644
--- a/tests/test_templates.py
+++ b/.tests/test_templates.py
@@ -68,6 +68,17 @@ class TestTemplates:
def test_template_exists(self, template):
assert (TEMPLATES_DIR / template).exists()
+ def test_index_has_find_me_before_search(self):
+ template = (TEMPLATES_DIR / "index.html").read_text(encoding="utf-8")
+ assert template.index('id="findMeBtn"') < template.index('id="searchInput"')
+
+ def test_xlsx_label_is_access_neutral(self):
+ template = (TEMPLATES_DIR / "index.html").read_text(encoding="utf-8")
+ locale = (STATIC_DIR / "locales" / "en-US.json").read_text(encoding="utf-8")
+ assert "XLSX (Full)" not in template
+ assert "XLSX (Full)" not in locale
+ assert "XLSX (Admin)" not in locale
+
# ---------------------------------------------------------------------------
# reports.html ↔ reports.js DOM ID consistency
diff --git a/AGENTS.md b/AGENTS.md
index 1899af9..a98b12b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,7 +54,18 @@ For multi-step tasks, state a brief plan:
3. [Step] → verify: [check]
```
-## 5. Project Architecture
+## 5. Build and Test Cleanup
+
+**Remove temporary artifacts created by your own validation runs.**
+
+After running a build or test command:
+- Identify temporary files and directories created by that run and delete them when they are no longer needed.
+- Delete only artifacts you can confirm were created by your current work or validation command.
+- Never delete pre-existing files, tracked files, test fixtures, user data, application state, or caches required by the project.
+- Preserve failure artifacts until they have been inspected; remove them afterward if they are no longer needed for diagnosis.
+- Check the working tree after cleanup to confirm that only intentional changes remain.
+
+## 6. Project Architecture
**Flask + vanilla JS + D3. No ORM, no front-end framework.**
@@ -75,7 +86,7 @@ static/
data/ ← JSON caches written at sync time (git-ignored)
```
-## 6. Adding a Report Filter
+## 7. Adding a Report Filter
Every filter touches all of these layers — miss one and it silently does nothing:
@@ -85,7 +96,7 @@ Every filter touches all of these layers — miss one and it silently does nothi
4. **`static/reports.js`** — add the filter object to `_standardToggleFilters()` or `TAGPICKER_FILTERS`; add `requiredCapability` if the filter needs a Graph permission beyond `User.Read.All`.
5. **`static/locales/en-US.json`** — add label and (for tagpickers) placeholder/mode keys.
-## 7. Graph Permission & Capability Gating
+## 8. Graph Permission & Capability Gating
Filters that depend on Graph permissions beyond the base `User.Read.All` must be gated:
@@ -105,7 +116,7 @@ When adding a new filter that needs a Graph permission:
1. Add a probe call in `probe_graph_capabilities()` if the capability isn't already detected.
2. Set `requiredCapability: ''` on the filter object in `reports.js`.
-## 8. Data Flow: Sync → Cache → API → UI
+## 9. Data Flow: Sync → Cache → API → UI
```
data_update.run_data_update()
@@ -120,7 +131,7 @@ GET /api/reports/ → load cache → apply_*_filters() → JSON resp
GET /api/graph-capabilities → data/graph_capabilities.json → JSON response
```
-## 9. i18n Rules
+## 10. i18n Rules
- Every user-visible string must have a key in `static/locales/en-US.json`.
- The translator `t(key)` is available in `reports.js` via `getTranslator()`.
diff --git a/README.md b/README.md
index 7034652..70ffec6 100644
--- a/README.md
+++ b/README.md
@@ -67,6 +67,48 @@ cp .env.template .env
- `ADMIN_PASSWORD` – Protects `/configure` and `/reports`.
- `SECRET_KEY` – 64+ character random string for Flask sessions.
+### Optional Microsoft Entra Sign-In
+
+Set `AUTH_TYPE=oidc` to require Microsoft sign-in for the entire app. The default, `AUTH_TYPE=simple`, keeps the existing public chart and password-based administrator login. OIDC uses a second App Registration, separate from the application-permission registration used to sync org data.
+
+Configure the sign-in App Registration as a **Web** application with redirect URI `https:///auth/callback` and grant these delegated Microsoft Graph permissions with tenant admin consent:
+
+- `User.Read`
+- `GroupMember.Read.All`
+
+In **Enterprise applications** (not App registrations), open the corresponding application and configure:
+
+1. Under **Properties**, set **Assignment required?** to **Yes**.
+2. Under **Users and groups**, assign the reader, privileged, and admin security groups used below.
+3. Do not rely on tenant membership alone; the application also verifies membership in one of these three groups during every new login.
+
+Microsoft Entra rejects an unassigned user before redirecting back to the org chart. Users assigned to the Enterprise Application but absent from all three access groups are denied by the application with a 403 error.
+
+Then configure:
+
+| Variable | Description |
+| --- | --- |
+| `AUTH_TYPE` | Authentication mode: `simple` or `oidc`. Defaults to `simple`. |
+| `OIDC_TENANT_ID` | Tenant ID for the sign-in App Registration. |
+| `OIDC_CLIENT_ID` | Client ID for the sign-in App Registration. |
+| `OIDC_CLIENT_SECRET` | Client secret for the sign-in App Registration. |
+| `OIDC_READER_GROUP_ID` | Security group object ID allowed to read the org chart. |
+| `OIDC_PRIVILEGED_GROUP_ID` | Security group object ID allowed to sync, use admin XLSX columns, and access reports. |
+| `OIDC_ADMIN_GROUP_ID` | Security group object ID granted full configuration access in addition to privileged access. |
+| `OIDC_REDIRECT_URI` | Optional callback override when the externally visible URL cannot be inferred. |
+
+Users outside all three configured groups are denied by the application even if Microsoft completes authentication. Membership is resolved through Microsoft Graph's transitive membership check, so nested group membership is honored. Privileged membership takes precedence over reader membership, and admin membership takes precedence over both.
+
+The configuration page displays the three OIDC group IDs as read-only values. Full admin group members always have every application capability. From the same page, an admin can independently grant these capabilities to the privileged group:
+
+- Reports, report exports, and user-scanner tools
+- Manual data synchronization
+- XLSX columns marked `Restricted`
+
+Existing XLSX column settings stored as `admin` are retained for compatibility but are displayed as `Restricted`; visibility is determined by the restricted-XLSX capability rather than the user's role name.
+
+With `AUTH_TYPE=simple`, the existing public chart and `ADMIN_PASSWORD` administrator flow remain unchanged. `ADMIN_PASSWORD` is not required with `AUTH_TYPE=oidc`.
+
Generate a strong secret key:
```bash
diff --git a/pyproject.toml b/pyproject.toml
index a498f38..c5e1680 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,3 @@
[tool.pytest.ini_options]
-testpaths = ["tests"]
+testpaths = [".tests"]
pythonpath = ["."]
diff --git a/requirements.txt b/requirements.txt
index 535101f..0d062b1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,6 +9,7 @@ Flask-Limiter==4.1.1
Flask-Session==0.8.0
gunicorn==26.0.0
marshmallow==4.3.0
+msal==1.32.3
openpyxl==3.1.5
Pillow==12.3.0
playwright==1.61.0
diff --git a/simple_org_chart/app_main.py b/simple_org_chart/app_main.py
index 87ec2dc..dc5ed88 100644
--- a/simple_org_chart/app_main.py
+++ b/simple_org_chart/app_main.py
@@ -3,6 +3,7 @@
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_session import Session
+from cachelib.file import FileSystemCache
import atexit
import contextlib
try:
@@ -38,6 +39,8 @@ def flock(fd, op):
import time
from io import BytesIO
import logging
+import requests
+from functools import wraps
from dotenv import load_dotenv
from werkzeug.utils import secure_filename
@@ -58,7 +61,23 @@ def flock(fd, op):
import simple_org_chart.config as app_config
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.auth import (
+ ROLE_ADMIN,
+ ROLE_PRIVILEGED,
+ current_role,
+ has_role,
+ is_authenticated,
+ privileged_login_required,
+ require_auth,
+ sanitize_next_path,
+)
+from simple_org_chart.oidc_auth import (
+ OidcConfig,
+ begin_login,
+ complete_login,
+ fetch_user_and_groups,
+ resolve_role,
+)
from simple_org_chart.email_config import (
DEFAULT_EMAIL_CONFIG,
get_smtp_config,
@@ -152,6 +171,54 @@ def flock(fd, op):
)
logger = logging.getLogger(__name__)
+PRIVILEGED_PERMISSION_DEFAULTS = {
+ 'reports': True,
+ 'sync': True,
+ 'restrictedXlsx': True,
+}
+
+
+def has_app_permission(permission):
+ """Return whether the current user has an application capability."""
+ if has_role(ROLE_ADMIN):
+ return True
+ if current_role() != ROLE_PRIVILEGED:
+ return False
+ configured = load_settings().get('oidcPrivilegedPermissions', {}) or {}
+ return bool(configured.get(permission, PRIVILEGED_PERMISSION_DEFAULTS.get(permission, False)))
+
+
+def require_app_permission(permission):
+ def decorator(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if not is_authenticated():
+ return jsonify({'error': 'Authentication required'}), 401
+ if not has_app_permission(permission):
+ return jsonify({'error': 'Insufficient permissions'}), 403
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+def require_page_permission(permission):
+ def decorator(func):
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if not is_authenticated():
+ desired_path = sanitize_next_path(request.path)
+ params = {'next': desired_path} if desired_path else {}
+ return redirect(url_for('login', **params))
+ if not has_app_permission(permission):
+ return 'Forbidden', 403
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
def _parse_port(raw_value, fallback):
try:
port = int(raw_value)
@@ -245,6 +312,10 @@ def _get_known_employee_ids() -> Optional[set[str]]:
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', secrets.token_hex(32))
app.config['SESSION_TYPE'] = SESSION_TYPE
app.config['SESSION_PERMANENT'] = False
+session_file_dir = os.environ.get('SESSION_FILE_DIR')
+if SESSION_TYPE == 'filesystem' and session_file_dir:
+ app.config['SESSION_TYPE'] = 'cachelib'
+ app.config['SESSION_CACHELIB'] = FileSystemCache(cache_dir=session_file_dir)
# Initialize extensions
Session(app)
@@ -266,11 +337,61 @@ def _is_localhost_exempt():
# Exempt localhost from all rate limits (health checks, internal requests)
limiter.request_filter(_is_localhost_exempt)
-# Simple authentication settings
+
+@app.before_request
+def require_oidc_reader():
+ """Require Microsoft sign-in for the entire app when OIDC mode is enabled."""
+ if AUTH_TYPE != 'oidc':
+ return None
+
+ if is_authenticated() and session.get('oidc_session_version') == OIDC_SESSION_VERSION:
+ return None
+
+ if is_authenticated():
+ session.clear()
+
+ public_endpoints = {
+ 'login',
+ 'oidc_callback',
+ 'serve_custom_favicon',
+ 'serve_custom_logo',
+ 'serve_static',
+ 'static',
+ }
+ if request.endpoint in public_endpoints or request.path == '/favicon.ico':
+ return None
+ if request.path.startswith('/api/'):
+ return jsonify({'error': 'Authentication required'}), 401
+
+ desired_path = sanitize_next_path(request.path)
+ params = {'next': desired_path} if desired_path else {}
+ return redirect(url_for('login', **params))
+
+# Authentication settings
+AUTH_TYPE = os.environ.get('AUTH_TYPE', 'simple').strip().lower()
+if AUTH_TYPE not in {'simple', 'oidc'}:
+ raise RuntimeError("AUTH_TYPE must be either 'simple' or 'oidc'")
+OIDC_SESSION_VERSION = 3
+
+OIDC_CONFIG = OidcConfig(
+ tenant_id=os.environ.get('OIDC_TENANT_ID', '').strip(),
+ client_id=os.environ.get('OIDC_CLIENT_ID', '').strip(),
+ client_secret=os.environ.get('OIDC_CLIENT_SECRET', '').strip(),
+ reader_group_id=os.environ.get('OIDC_READER_GROUP_ID', '').strip(),
+ privileged_group_id=os.environ.get('OIDC_PRIVILEGED_GROUP_ID', '').strip(),
+ admin_group_id=os.environ.get('OIDC_ADMIN_GROUP_ID', '').strip(),
+)
+if AUTH_TYPE == 'oidc' and not all(vars(OIDC_CONFIG).values()):
+ raise RuntimeError(
+ 'OIDC authentication requires OIDC_TENANT_ID, OIDC_CLIENT_ID, '
+ 'OIDC_CLIENT_SECRET, OIDC_READER_GROUP_ID, OIDC_PRIVILEGED_GROUP_ID, '
+ 'and OIDC_ADMIN_GROUP_ID'
+ )
+
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD')
-if not ADMIN_PASSWORD:
+if AUTH_TYPE == 'simple' and not ADMIN_PASSWORD:
raise RuntimeError('ADMIN_PASSWORD environment variable must be set to a strong value')
-if ADMIN_PASSWORD in {'admin123', 'your-admin-password-here'}:
+if AUTH_TYPE == 'simple' and ADMIN_PASSWORD in {'admin123', 'your-admin-password-here'}:
raise RuntimeError('ADMIN_PASSWORD must not use the default placeholder value')
# Security headers
@@ -302,16 +423,6 @@ def add_security_headers(response):
MISSING_HIRE_DATE_FILE = str(app_config.MISSING_HIRE_DATE_FILE)
DATA_UPDATE_STATUS_FILE = os.path.join(DATA_DIR, 'data_update_status.json')
-# Always delete the capabilities file on startup — it is regenerated on the
-# next sync via JWT-decode. This ensures no stale data from previous probe
-# implementations (API-call based) persists across restarts.
-try:
- if os.path.exists(GRAPH_CAPABILITIES_FILE):
- os.remove(GRAPH_CAPABILITIES_FILE)
- logger.info("Removed graph_capabilities.json on startup; will regenerate on next sync.")
-except Exception as _cap_startup_err:
- logger.warning("Could not remove graph_capabilities.json on startup: %s", _cap_startup_err)
-
logger.info(f"DATA_DIR set to: {DATA_DIR}")
TENANT_ID = os.environ.get('AZURE_TENANT_ID')
@@ -383,6 +494,15 @@ def get_template(template_name):
def login():
next_page = sanitize_next_path(request.args.get('next', ''))
+ if AUTH_TYPE == 'oidc':
+ if is_authenticated():
+ return redirect('/')
+ redirect_uri = os.environ.get('OIDC_REDIRECT_URI') or url_for('oidc_callback', _external=True)
+ flow = begin_login(OIDC_CONFIG, redirect_uri)
+ session['oidc_flow'] = flow
+ session['oidc_next'] = next_page
+ return redirect(flow['auth_uri'])
+
if request.method == 'POST':
try:
payload = request.get_json(silent=True)
@@ -401,6 +521,7 @@ def login():
if password == ADMIN_PASSWORD:
session['authenticated'] = True
session['username'] = 'admin'
+ session['role'] = ROLE_ADMIN
logger.info("Successful login")
return jsonify({
'success': True,
@@ -424,6 +545,56 @@ def login():
favicon_path=favicon_path
)
+
+@app.route('/auth/callback')
+def oidc_callback():
+ if AUTH_TYPE != 'oidc':
+ return redirect(url_for('login'))
+
+ flow = session.pop('oidc_flow', None)
+ if not flow:
+ return redirect(url_for('login'))
+
+ try:
+ result = complete_login(OIDC_CONFIG, flow, request.args.to_dict())
+ access_token = result.get('access_token')
+ if not access_token:
+ logger.warning('OIDC login failed: %s', result.get('error_description', 'unknown error'))
+ return 'Microsoft sign-in failed', 401
+
+ user, group_ids = fetch_user_and_groups(
+ access_token,
+ {
+ OIDC_CONFIG.reader_group_id,
+ OIDC_CONFIG.privileged_group_id,
+ OIDC_CONFIG.admin_group_id,
+ },
+ )
+ role = resolve_role(OIDC_CONFIG, group_ids)
+ if role is None:
+ username = (
+ user.get('userPrincipalName') or user.get('mail') or user.get('displayName') or 'user'
+ )
+ logger.warning('OIDC login denied for unassigned user %s', username)
+ session.clear()
+ return 'You are not assigned to this application', 403
+
+ session['authenticated'] = True
+ session['role'] = role
+ session['oidc_session_version'] = OIDC_SESSION_VERSION
+ session['oidc_user_id'] = str(user.get('id') or '')
+ session['oidc_user_email'] = str(user.get('mail') or user.get('userPrincipalName') or '')
+ session['username'] = (
+ user.get('userPrincipalName') or user.get('mail') or user.get('displayName') or 'user'
+ )
+ logger.info('OIDC login successful for %s with role %s', session['username'], role)
+ next_page = sanitize_next_path(session.pop('oidc_next', ''))
+ return redirect(f'/{next_page}' if next_page else '/')
+ except (ValueError, KeyError, requests.RequestException) as error:
+ logger.warning('OIDC callback failed: %s', error)
+ session.clear()
+ return 'Microsoft sign-in failed', 401
+
@app.route('/logout', methods=['POST'])
def logout():
session.clear()
@@ -442,12 +613,10 @@ def index():
return render_template_string(template_content)
@app.route('/configure')
+@privileged_login_required
def configure():
- if not session.get('authenticated'):
- # Redirect to login preserving the intended destination
- desired_path = sanitize_next_path(request.path)
- params = {'next': desired_path} if desired_path else {}
- return redirect(url_for('login', **params))
+ if not has_role(ROLE_ADMIN):
+ return 'Forbidden', 403
template_content = get_template('configure.html')
settings = load_settings()
@@ -464,12 +633,16 @@ def configure():
chart_title=chart_title,
logo_path=logo_path,
favicon_image_path=favicon_path,
+ auth_type=AUTH_TYPE,
+ oidc_reader_group_id=OIDC_CONFIG.reader_group_id,
+ oidc_privileged_group_id=OIDC_CONFIG.privileged_group_id,
+ oidc_admin_group_id=OIDC_CONFIG.admin_group_id,
_=translate_placeholder
)
@app.route('/reports')
-@login_required
+@require_page_permission('reports')
def reports():
template_content = get_template('reports.html')
settings = load_settings()
@@ -828,8 +1001,10 @@ def handle_settings():
elif request.method == 'POST':
# POST requires authentication
- if not session.get('authenticated'):
+ if not is_authenticated():
return jsonify({'error': 'Authentication required'}), 401
+ if not has_role(ROLE_ADMIN):
+ return jsonify({'error': 'Insufficient permissions'}), 403
try:
# Simply update settings without validation
@@ -850,7 +1025,7 @@ def handle_settings():
@app.route('/api/metadata/options')
-@require_auth
+@require_app_permission('reports')
def get_metadata_options():
employees = get_employee_list_for_metadata()
job_titles = collect_unique_field_values(employees, 'title')
@@ -869,7 +1044,7 @@ def get_metadata_options():
@app.route('/api/graph-capabilities')
-@require_auth
+@require_app_permission('reports')
def get_graph_capabilities():
"""Return the Graph API capability flags detected during the last sync."""
if not os.path.exists(GRAPH_CAPABILITIES_FILE):
@@ -1429,7 +1604,7 @@ def export_xlsx():
hide_no_title = settings.get('hideNoTitle', True)
ignored_departments = parse_ignored_departments(settings)
export_column_settings = settings.get('exportXlsxColumns', {}) or {}
- is_admin = bool(session.get('authenticated'))
+ can_access_restricted_columns = has_app_permission('restrictedXlsx')
column_definitions = [
('name', 'Name', lambda node, manager: node.get('name', '')),
@@ -1452,9 +1627,9 @@ def column_is_visible(key):
normalized_admin = mode.replace('_', '').replace('-', '')
if mode == 'hide':
return False
- if mode == 'admin' and not is_admin:
+ if mode == 'admin' and not can_access_restricted_columns:
return False
- if normalized_admin in {'showadminonly', 'adminonly'} and not is_admin:
+ if normalized_admin in {'showadminonly', 'adminonly'} and not can_access_restricted_columns:
return False
return True
@@ -1582,7 +1757,7 @@ def _get_disabled_records_from_request(*, force_refresh=False, apply_filters=Tru
@app.route('/api/reports/missing-manager')
-@require_auth
+@require_app_permission('reports')
def get_missing_manager_report():
try:
refresh = _parse_bool_arg(request.args.get('refresh'), default=False)
@@ -1610,7 +1785,7 @@ def get_missing_manager_report():
@app.route('/api/reports/missing-manager/export')
-@require_auth
+@require_app_permission('reports')
def export_missing_manager_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -1691,7 +1866,7 @@ def export_missing_manager_report():
@app.route('/api/reports/missing-photo')
-@require_auth
+@require_app_permission('reports')
def get_missing_photo_report():
try:
refresh = _parse_bool_arg(request.args.get('refresh'), default=False)
@@ -1719,7 +1894,7 @@ def get_missing_photo_report():
@app.route('/api/reports/missing-photo/export')
-@require_auth
+@require_app_permission('reports')
def export_missing_photo_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -1787,7 +1962,7 @@ def export_missing_photo_report():
@app.route('/api/reports/missing-hire-date')
-@require_auth
+@require_app_permission('reports')
def get_missing_hire_date_report():
try:
refresh = _parse_bool_arg(request.args.get('refresh'), default=False)
@@ -1815,7 +1990,7 @@ def get_missing_hire_date_report():
@app.route('/api/reports/missing-hire-date/export')
-@require_auth
+@require_app_permission('reports')
def export_missing_hire_date_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -1883,7 +2058,7 @@ def export_missing_hire_date_report():
@app.route('/api/reports/dirty-data')
-@require_auth
+@require_app_permission('reports')
def get_dirty_data_report():
try:
refresh = _parse_bool_arg(request.args.get('refresh'), default=False)
@@ -1923,7 +2098,7 @@ def get_dirty_data_report():
@app.route('/api/reports/dirty-data/export')
-@require_auth
+@require_app_permission('reports')
def export_dirty_data_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2004,7 +2179,7 @@ def export_dirty_data_report():
@app.route('/api/reports/disabled-users')
-@require_auth
+@require_app_permission('reports')
def get_disabled_users_report():
try:
refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -2029,7 +2204,7 @@ def get_disabled_users_report():
@app.route('/api/reports/disabled-users/export')
-@require_auth
+@require_app_permission('reports')
def export_disabled_users_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2106,7 +2281,7 @@ def export_disabled_users_report():
@app.route('/api/reports/disabled-this-year')
-@require_auth
+@require_app_permission('reports')
def get_recently_disabled_report():
try:
refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -2154,7 +2329,7 @@ def get_recently_disabled_report():
@app.route('/api/reports/disabled-this-year/export')
-@require_auth
+@require_app_permission('reports')
def export_recently_disabled_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2247,7 +2422,7 @@ def export_recently_disabled_report():
@app.route('/api/reports/hired-this-year')
-@require_auth
+@require_app_permission('reports')
def get_recently_hired_report():
try:
refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -2271,7 +2446,7 @@ def get_recently_hired_report():
@app.route('/api/reports/hired-this-year/export')
-@require_auth
+@require_app_permission('reports')
def export_recently_hired_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2451,7 +2626,7 @@ def _apply_scope_filter(records, scope):
@app.route('/api/reports/last-logins')
-@require_auth
+@require_app_permission('reports')
def get_last_logins_report():
try:
refresh = _parse_bool_arg(request.args.get('refresh'), default=False)
@@ -2546,7 +2721,7 @@ def get_last_logins_report():
@app.route('/api/reports/last-logins/export')
-@require_auth
+@require_app_permission('reports')
def export_last_logins_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2690,7 +2865,7 @@ def export_last_logins_report():
@app.route('/api/reports/disabled-licensed')
-@require_auth
+@require_app_permission('reports')
def get_disabled_licensed_report():
try:
refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -2727,7 +2902,7 @@ def get_disabled_licensed_report():
@app.route('/api/reports/disabled-licensed/export')
-@require_auth
+@require_app_permission('reports')
def export_disabled_licensed_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2803,7 +2978,7 @@ def export_disabled_licensed_report():
@app.route('/api/reports/filtered-users')
-@require_auth
+@require_app_permission('reports')
def get_filtered_users_report():
try:
refresh = _parse_bool_arg(request.args.get('refresh'), default=False)
@@ -2869,7 +3044,7 @@ def get_filtered_users_report():
@app.route('/api/reports/filtered-users/export')
-@require_auth
+@require_app_permission('reports')
def export_filtered_users_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -2998,7 +3173,7 @@ def export_filtered_users_report():
@app.route('/api/reports/filtered-licensed')
-@require_auth
+@require_app_permission('reports')
def get_filtered_licensed_report():
try:
refresh = request.args.get('refresh', 'false').lower() == 'true'
@@ -3018,7 +3193,7 @@ def get_filtered_licensed_report():
@app.route('/api/reports/filtered-licensed/export')
-@require_auth
+@require_app_permission('reports')
def export_filtered_licensed_report():
if not Workbook:
return jsonify({'error': 'XLSX export not available - openpyxl not installed'}), 500
@@ -3096,12 +3271,29 @@ def export_filtered_licensed_report():
@app.route('/api/auth-check')
def auth_check():
- """Simple endpoint to check if user is authenticated"""
- if session.get('authenticated'):
- return jsonify({'authenticated': True})
- else:
+ """Return the caller's authentication role and UI capabilities."""
+ if not is_authenticated():
return jsonify({'authenticated': False}), 401
+ role = current_role()
+ payload = {
+ 'authenticated': True,
+ 'role': role,
+ 'canAccessReports': has_app_permission('reports'),
+ 'canSync': has_app_permission('sync'),
+ 'canAccessRestrictedXlsx': has_app_permission('restrictedXlsx'),
+ 'canAdminister': has_role(ROLE_ADMIN),
+ 'authType': AUTH_TYPE,
+ }
+ if AUTH_TYPE == 'oidc':
+ payload['user'] = {
+ 'id': session.get('oidc_user_id', ''),
+ 'email': session.get('oidc_user_email', ''),
+ }
+ response = jsonify(payload)
+ response.headers['Cache-Control'] = 'no-store'
+ return response
+
# ---------------------------------------------------------------------------
# User Scanner API routes
# ---------------------------------------------------------------------------
@@ -3156,7 +3348,7 @@ def flatten(node, out=None):
@app.route('/api/user-scanner/users')
-@require_auth
+@require_app_permission('reports')
def user_scanner_users():
"""Search all non-guest users (incl. disabled/filtered) for the scanner."""
settings = load_settings()
@@ -3183,7 +3375,7 @@ def user_scanner_users():
@app.route('/api/user-scanner/status')
-@require_auth
+@require_app_permission('reports')
def user_scanner_status():
"""Return installation / enablement status for the user-scanner tool."""
settings = load_settings()
@@ -3198,7 +3390,7 @@ def user_scanner_status():
@app.route('/api/user-scanner/check-update')
-@require_auth
+@require_app_permission('reports')
def user_scanner_check_update():
"""Check PyPI for a newer version and optionally auto-upgrade."""
info = user_scanner_service.check_for_update()
@@ -3206,7 +3398,7 @@ def user_scanner_check_update():
@app.route('/api/user-scanner/update', methods=['POST'])
-@require_auth
+@require_app_permission('reports')
def user_scanner_update():
"""Upgrade user-scanner to the latest PyPI release."""
success = user_scanner_service.install() # install() uses --upgrade
@@ -3217,7 +3409,7 @@ def user_scanner_update():
@app.route('/api/user-scanner/install', methods=['POST'])
-@require_auth
+@require_app_permission('reports')
def user_scanner_install():
"""Download / upgrade user-scanner from PyPI."""
success = user_scanner_service.install()
@@ -3228,7 +3420,7 @@ def user_scanner_install():
@app.route('/api/user-scanner/sites')
-@require_auth
+@require_app_permission('reports')
def user_scanner_sites():
"""Return the list of all site names the scanner can check."""
if not user_scanner_service.is_installed():
@@ -3242,7 +3434,7 @@ def user_scanner_sites():
@app.route('/api/user-scanner/loud-sites')
-@require_auth
+@require_app_permission('reports')
def user_scanner_loud_sites():
"""Return the list of site names that are considered loud."""
if not user_scanner_service.is_installed():
@@ -3256,7 +3448,7 @@ def user_scanner_loud_sites():
@app.route('/api/user-scanner/categories')
-@require_auth
+@require_app_permission('reports')
def user_scanner_categories():
"""Return the list of scanner category names."""
if not user_scanner_service.is_installed():
@@ -3270,7 +3462,7 @@ def user_scanner_categories():
@app.route('/api/user-scanner/scan', methods=['POST'])
-@require_auth
+@require_app_permission('reports')
def user_scanner_scan():
"""Run an on-demand scan for a single user (email and/or username)."""
settings = load_settings()
@@ -3416,7 +3608,7 @@ def _reset_stale_scan_state():
@app.route('/api/user-scanner/full-scan', methods=['POST'])
-@require_auth
+@require_app_permission('reports')
def user_scanner_full_scan():
"""Run a full org scan in a background thread. Optionally email results."""
settings = load_settings()
@@ -3611,7 +3803,7 @@ def _background_full_scan():
@app.route('/api/user-scanner/full-scan/status')
-@require_auth
+@require_app_permission('reports')
def user_scanner_full_scan_status():
"""Return the current state of the background full-scan."""
with _full_scan_lock:
@@ -3628,7 +3820,7 @@ def user_scanner_full_scan_status():
@app.route('/api/user-scanner/full-scan/stop', methods=['POST'])
-@require_auth
+@require_app_permission('reports')
def user_scanner_full_scan_stop():
"""Signal the running full-scan to stop after the current employee."""
with _full_scan_lock:
@@ -3640,14 +3832,14 @@ def user_scanner_full_scan_stop():
@app.route('/api/user-scanner/full-scan/history')
-@require_auth
+@require_app_permission('reports')
def user_scanner_full_scan_history():
"""Return metadata for the last N scan runs."""
return jsonify(user_scanner_service.load_scan_history())
@app.route('/api/user-scanner/full-scan/history', methods=['DELETE'])
-@require_auth
+@require_app_permission('reports')
def user_scanner_clear_scan_history():
"""Delete all scan history entries and XLSX files."""
removed = user_scanner_service.clear_scan_history()
@@ -3655,7 +3847,7 @@ def user_scanner_clear_scan_history():
@app.route('/api/user-scanner/full-scan/download/')
-@require_auth
+@require_app_permission('reports')
def user_scanner_full_scan_download(scan_id):
"""Serve the XLSX file for a given scan run."""
xlsx_path = user_scanner_service.get_xlsx_path(scan_id)
@@ -3670,7 +3862,7 @@ def user_scanner_full_scan_download(scan_id):
@app.route('/api/user-scanner/full-scan/results')
-@require_auth
+@require_app_permission('reports')
def user_scanner_full_scan_results():
"""Return cached full-scan results."""
cached = user_scanner_service.load_cached_full_scan()
@@ -3840,7 +4032,7 @@ def find_employee(node, target_id):
return jsonify({'error': 'Internal server error'}), 500
@app.route('/api/update-now', methods=['POST'])
-@require_auth
+@require_app_permission('sync')
@limiter.limit(RATE_LIMIT_REFRESH)
def trigger_update():
try:
@@ -3977,7 +4169,7 @@ def flatten(node, results=None):
@app.route('/api/force-update', methods=['POST'])
-@require_auth
+@require_app_permission('sync')
@limiter.limit(RATE_LIMIT_REFRESH)
def force_update():
"""Force an immediate update and wait for completion"""
diff --git a/simple_org_chart/auth.py b/simple_org_chart/auth.py
index acbcc80..17d8341 100644
--- a/simple_org_chart/auth.py
+++ b/simple_org_chart/auth.py
@@ -8,6 +8,14 @@
NextHandler = Callable[..., Any]
_next_path_pattern = re.compile(r"^[A-Za-z0-9_\-/]*$")
+ROLE_READER = "reader"
+ROLE_PRIVILEGED = "privileged"
+ROLE_ADMIN = "admin"
+_ROLE_LEVELS = {
+ ROLE_READER: 1,
+ ROLE_PRIVILEGED: 2,
+ ROLE_ADMIN: 3,
+}
def sanitize_next_path(raw_value: str | None) -> str:
@@ -28,30 +36,90 @@ def sanitize_next_path(raw_value: str | None) -> str:
return candidate
+def is_authenticated() -> bool:
+ return bool(session.get("authenticated"))
+
+
+def current_role() -> str | None:
+ if not is_authenticated():
+ return None
+ return session.get("role", ROLE_ADMIN)
+
+
+def has_role(required_role: str) -> bool:
+ required_level = _ROLE_LEVELS.get(required_role)
+ if required_level is None:
+ return False
+ return _ROLE_LEVELS.get(current_role() or "", 0) >= required_level
+
+
+def _require_api_role(required_role: str):
+ def decorator(func: NextHandler) -> NextHandler:
+ @wraps(func)
+ def wrapper(*args: Any, **kwargs: Dict[str, Any]):
+ if not is_authenticated():
+ return jsonify({"error": "Authentication required"}), 401
+ if not has_role(required_role):
+ return jsonify({"error": "Insufficient permissions"}), 403
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
def require_auth(func: NextHandler) -> NextHandler:
- """API decorator that ensures the caller is authenticated."""
+ """API decorator that requires full administrator access."""
+
+ return _require_api_role(ROLE_ADMIN)(func)
+
+
+def require_privileged(func: NextHandler) -> NextHandler:
+ """API decorator for reports, sync, and privileged exports."""
+
+ return _require_api_role(ROLE_PRIVILEGED)(func)
+
+
+def login_required(func: NextHandler) -> NextHandler:
+ """Route decorator that redirects to the login page when unauthenticated."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Dict[str, Any]):
- if not session.get("authenticated"):
- return jsonify({"error": "Authentication required"}), 401
+ if not is_authenticated():
+ desired_path = sanitize_next_path(request.path)
+ params: Dict[str, Any] = {"next": desired_path} if desired_path else {}
+ return redirect(url_for("login", **params))
return func(*args, **kwargs)
return wrapper
-def login_required(func: NextHandler) -> NextHandler:
- """Route decorator that redirects to the login page when unauthenticated."""
+def privileged_login_required(func: NextHandler) -> NextHandler:
+ """Page decorator that requires report and sync access."""
@wraps(func)
def wrapper(*args: Any, **kwargs: Dict[str, Any]):
- if not session.get("authenticated"):
+ if not is_authenticated():
desired_path = sanitize_next_path(request.path)
params: Dict[str, Any] = {"next": desired_path} if desired_path else {}
return redirect(url_for("login", **params))
+ if not has_role(ROLE_PRIVILEGED):
+ return "Forbidden", 403
return func(*args, **kwargs)
return wrapper
-__all__ = ["login_required", "require_auth", "sanitize_next_path"]
+__all__ = [
+ "ROLE_ADMIN",
+ "ROLE_PRIVILEGED",
+ "ROLE_READER",
+ "current_role",
+ "has_role",
+ "is_authenticated",
+ "login_required",
+ "privileged_login_required",
+ "require_auth",
+ "require_privileged",
+ "sanitize_next_path",
+]
diff --git a/simple_org_chart/config.py b/simple_org_chart/config.py
index a580a4c..e541126 100644
--- a/simple_org_chart/config.py
+++ b/simple_org_chart/config.py
@@ -10,7 +10,9 @@
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent.parent
-DATA_DIR = BASE_DIR / "data"
+DATA_DIR = Path(
+ os.environ.get("SIMPLE_ORG_CHART_DATA_DIR", BASE_DIR / "data")
+).resolve()
CONFIG_DIR = BASE_DIR / "config"
STATIC_DIR = BASE_DIR / "static"
TEMPLATE_DIR = BASE_DIR / "templates"
diff --git a/simple_org_chart/oidc_auth.py b/simple_org_chart/oidc_auth.py
new file mode 100644
index 0000000..819de8d
--- /dev/null
+++ b/simple_org_chart/oidc_auth.py
@@ -0,0 +1,92 @@
+"""Microsoft Entra OpenID Connect authentication helpers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import msal
+import requests
+
+GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
+OIDC_SCOPES = ["User.Read", "GroupMember.Read.All"]
+
+
+@dataclass(frozen=True)
+class OidcConfig:
+ tenant_id: str
+ client_id: str
+ client_secret: str
+ reader_group_id: str
+ privileged_group_id: str
+ admin_group_id: str
+
+ @property
+ def authority(self) -> str:
+ return f"https://login.microsoftonline.com/{self.tenant_id}"
+
+
+def build_client(config: OidcConfig) -> msal.ConfidentialClientApplication:
+ return msal.ConfidentialClientApplication(
+ config.client_id,
+ authority=config.authority,
+ client_credential=config.client_secret,
+ )
+
+
+def begin_login(config: OidcConfig, redirect_uri: str) -> dict[str, Any]:
+ return build_client(config).initiate_auth_code_flow(
+ OIDC_SCOPES,
+ redirect_uri=redirect_uri,
+ )
+
+
+def complete_login(
+ config: OidcConfig,
+ flow: dict[str, Any],
+ callback_args: dict[str, str],
+) -> dict[str, Any]:
+ return build_client(config).acquire_token_by_auth_code_flow(flow, callback_args)
+
+
+def fetch_user_and_groups(
+ access_token: str,
+ configured_group_ids: set[str],
+) -> tuple[dict[str, Any], set[str]]:
+ headers = {"Authorization": f"Bearer {access_token}"}
+ user_response = requests.get(
+ f"{GRAPH_BASE_URL}/me?$select=id,displayName,userPrincipalName,mail",
+ headers=headers,
+ timeout=15,
+ )
+ user_response.raise_for_status()
+
+ normalized_group_ids = {
+ group_id.strip().lower()
+ for group_id in configured_group_ids
+ if group_id and group_id.strip()
+ }
+ group_response = requests.post(
+ f"{GRAPH_BASE_URL}/me/checkMemberGroups",
+ headers={**headers, "Content-Type": "application/json"},
+ json={"groupIds": sorted(normalized_group_ids)},
+ timeout=15,
+ )
+ group_response.raise_for_status()
+ group_ids = {
+ str(group_id).lower()
+ for group_id in group_response.json().get("value", [])
+ }
+
+ return user_response.json(), group_ids
+
+
+def resolve_role(config: OidcConfig, group_ids: set[str]) -> str | None:
+ normalized_ids = {group_id.lower() for group_id in group_ids}
+ if config.admin_group_id.strip().lower() in normalized_ids:
+ return "admin"
+ if config.privileged_group_id.strip().lower() in normalized_ids:
+ return "privileged"
+ if config.reader_group_id.strip().lower() in normalized_ids:
+ return "reader"
+ return None
\ No newline at end of file
diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py
index 229525c..431383d 100644
--- a/simple_org_chart/settings.py
+++ b/simple_org_chart/settings.py
@@ -160,6 +160,11 @@ def _settings_lock_file_factory() -> str:
"office": "show",
"manager": "show",
},
+ "oidcPrivilegedPermissions": {
+ "reports": True,
+ "sync": True,
+ "restrictedXlsx": True,
+ },
"topLevelUserEmail": "",
"topLevelUserId": "",
"newEmployeeMonths": 3,
diff --git a/static/app.js b/static/app.js
index 81ff553..f570220 100644
--- a/static/app.js
+++ b/static/app.js
@@ -11,6 +11,13 @@ let appSettings = {};
let currentLayout = 'vertical'; // Default layout
const hiddenNodeIds = new Set(JSON.parse(localStorage.getItem('hiddenNodeIds') || '[]'));
let isAuthenticated = false;
+let isSignedIn = false;
+let authType = 'simple';
+let canAccessReports = false;
+let canSync = false;
+let canAccessRestrictedXlsx = false;
+let oidcUser = null;
+let findMeEmployeeId = null;
const COMPACT_PREFERENCE_KEY = 'orgChart.compactLargeTeams';
let userCompactPreference = null;
const PROFILE_IMAGE_PREFERENCE_KEY = 'orgChart.showProfileImages';
@@ -1399,6 +1406,15 @@ function setupStaticEventListeners() {
});
}
+ const findMeBtn = document.getElementById('findMeBtn');
+ if (findMeBtn) {
+ findMeBtn.addEventListener('click', () => {
+ if (findMeEmployeeId) {
+ selectSearchResult(findMeEmployeeId);
+ }
+ });
+ }
+
document.querySelectorAll('[data-layout]').forEach(button => {
button.addEventListener('click', () => {
if (button.dataset.layout === 'toggle') {
@@ -1760,7 +1776,7 @@ async function applySettings() {
// Check if a sync is already in progress and show the syncing state
const updateStatus = appSettings.dataUpdateStatus || {};
- if (updateStatus.state === 'running' && isAuthenticated) {
+ if (updateStatus.state === 'running' && canSync) {
setSyncButtonState(true);
updateHeaderSubtitle(true); // Show syncing in header
startSyncPolling();
@@ -1887,22 +1903,22 @@ function updateAdminActions() {
const loginBtn = document.getElementById('loginBtn');
if (loginBtn) {
- loginBtn.classList.toggle('is-hidden', isAuthenticated);
+ loginBtn.classList.toggle('is-hidden', isSignedIn);
}
const syncBtn = document.getElementById('syncBtn');
if (syncBtn) {
- syncBtn.classList.toggle('is-hidden', !isAuthenticated);
+ syncBtn.classList.toggle('is-hidden', !canSync);
}
const reportsBtn = document.getElementById('reportsBtn');
if (reportsBtn) {
- reportsBtn.classList.toggle('is-hidden', !isAuthenticated);
+ reportsBtn.classList.toggle('is-hidden', !canAccessReports);
}
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
- logoutBtn.classList.toggle('is-hidden', !isAuthenticated);
+ logoutBtn.classList.toggle('is-hidden', !isSignedIn || authType === 'oidc');
}
}
@@ -1913,11 +1929,9 @@ async function updateAuthDependentUI() {
const exportXlsxBtn = document.querySelector('[data-control="export-xlsx"]');
if (exportXlsxBtn) {
const baseLabel = t('index.toolbar.controls.exportXlsx');
- const adminLabel = t('index.toolbar.controls.exportXlsxAdmin');
- const label = isAuthenticated ? adminLabel : baseLabel;
- exportXlsxBtn.textContent = label;
- exportXlsxBtn.setAttribute('aria-label', label);
- exportXlsxBtn.title = label;
+ exportXlsxBtn.textContent = baseLabel;
+ exportXlsxBtn.setAttribute('aria-label', baseLabel);
+ exportXlsxBtn.title = baseLabel;
}
const compactBtn = document.getElementById('compactToggleBtn');
@@ -2021,12 +2035,16 @@ async function updateAuthDependentUI() {
async function checkAuthentication() {
try {
const response = await fetch(`${API_BASE_URL}/api/auth-check`, {
- credentials: 'same-origin'
+ credentials: 'same-origin',
+ cache: 'no-store'
});
- return response.ok;
+ if (!response.ok) {
+ return { authenticated: false, canAccessReports: false, canSync: false, canAccessRestrictedXlsx: false, canAdminister: false, authType: 'simple' };
+ }
+ return await response.json();
} catch (error) {
console.error('Authentication check failed:', error);
- return false;
+ return { authenticated: false, canAccessReports: false, canSync: false, canAccessRestrictedXlsx: false, canAdminister: false, authType: 'simple' };
}
}
@@ -2034,7 +2052,14 @@ async function init() {
const htmlElement = document.documentElement;
try {
- isAuthenticated = await checkAuthentication();
+ const authState = await checkAuthentication();
+ isSignedIn = Boolean(authState.authenticated);
+ authType = authState.authType || 'simple';
+ canAccessReports = Boolean(authState.canAccessReports);
+ canSync = Boolean(authState.canSync);
+ canAccessRestrictedXlsx = Boolean(authState.canAccessRestrictedXlsx);
+ isAuthenticated = Boolean(authState.canAdminister);
+ oidcUser = authType === 'oidc' ? (authState.user || null) : null;
if (isAuthenticated) {
userCompactPreference = null;
} else {
@@ -2064,6 +2089,7 @@ async function init() {
if (currentData) {
employeeById.clear();
allEmployees = flattenTree(currentData);
+ updateFindMeAvailability();
const validIds = allEmployees.map(emp => emp.id).filter(Boolean);
pruneTitleOverrides(validIds);
pruneDepartmentOverrides(validIds);
@@ -2110,6 +2136,7 @@ async function refreshOrgChart() {
if (currentData) {
employeeById.clear();
allEmployees = flattenTree(currentData);
+ updateFindMeAvailability();
const validIds = allEmployees.map(emp => emp.id).filter(Boolean);
pruneTitleOverrides(validIds);
pruneDepartmentOverrides(validIds);
@@ -2874,6 +2901,7 @@ async function reloadEmployeeData() {
if (currentData) {
employeeById.clear();
allEmployees = flattenTree(currentData);
+ updateFindMeAvailability();
const validIds = allEmployees.map(emp => emp.id).filter(Boolean);
pruneTitleOverrides(validIds);
pruneDepartmentOverrides(validIds);
@@ -5460,6 +5488,30 @@ function selectSearchResult(employeeId) {
}
}
+function updateFindMeAvailability() {
+ const button = document.getElementById('findMeBtn');
+ findMeEmployeeId = null;
+ if (!button || authType !== 'oidc' || !oidcUser) {
+ if (button) button.classList.add('is-hidden');
+ return;
+ }
+
+ const userId = String(oidcUser.id || '').trim().toLowerCase();
+ const userEmail = String(oidcUser.email || '').trim().toLowerCase();
+ const employee = allEmployees.find(candidate => {
+ const candidateId = String(candidate.id || '').trim().toLowerCase();
+ const candidateEmail = String(candidate.email || candidate.userPrincipalName || '').trim().toLowerCase();
+ return (userId && candidateId === userId) || (userEmail && candidateEmail === userEmail);
+ });
+
+ if (employee && employee.id) {
+ findMeEmployeeId = employee.id;
+ button.classList.remove('is-hidden');
+ } else {
+ button.classList.add('is-hidden');
+ }
+}
+
function expandToEmployee(employeeId) {
if (appSettings.searchAutoExpand === false) {
const targetNode = findNodeById(root, employeeId);
diff --git a/static/configure.js b/static/configure.js
index 7d6ac29..748a293 100644
--- a/static/configure.js
+++ b/static/configure.js
@@ -1187,6 +1187,14 @@ function applySettings(settings) {
}
_initUserScannerConfigUI(settings.userScannerEnabled === true);
+ const privilegedPermissions = settings.oidcPrivilegedPermissions || {};
+ const reportsPermission = document.getElementById('oidcPermissionReports');
+ const syncPermission = document.getElementById('oidcPermissionSync');
+ const restrictedXlsxPermission = document.getElementById('oidcPermissionRestrictedXlsx');
+ if (reportsPermission) reportsPermission.checked = privilegedPermissions.reports !== false;
+ if (syncPermission) syncPermission.checked = privilegedPermissions.sync !== false;
+ if (restrictedXlsxPermission) restrictedXlsxPermission.checked = privilegedPermissions.restrictedXlsx !== false;
+
const updateMeta = settings.dataUpdateStatus || {};
if (updateMeta.state === 'running') {
updateLastUpdatedDisplay(resolveTranslation('configure.data.manualUpdate.lastUpdatedUpdating', 'Updating...'));
@@ -1680,6 +1688,11 @@ async function saveAllSettings() {
topLevelUserEmail: document.getElementById('topLevelUserInput')?.value || '',
topLevelUserId: document.getElementById('topLevelUserIdInput')?.value || '',
exportXlsxColumns: getExportColumnSettings(),
+ oidcPrivilegedPermissions: {
+ reports: document.getElementById('oidcPermissionReports')?.checked ?? true,
+ sync: document.getElementById('oidcPermissionSync')?.checked ?? true,
+ restrictedXlsx: document.getElementById('oidcPermissionRestrictedXlsx')?.checked ?? true
+ },
userScannerEnabled: document.getElementById('userScannerEnabled')?.checked || false,
teamsPresenceEnabled: document.getElementById('teamsPresenceEnabled')?.checked || false
};
diff --git a/static/locales/en-US.json b/static/locales/en-US.json
index 540a6df..fd9e787 100644
--- a/static/locales/en-US.json
+++ b/static/locales/en-US.json
@@ -27,7 +27,8 @@
},
"search": {
"placeholder": "Search for employees by name, title, or department...",
- "noResults": "No matches found"
+ "noResults": "No matches found",
+ "findMe": "Find Me"
},
"topUser": {
"label": "Top-Level User:",
@@ -86,8 +87,7 @@
"exportVisibleSvg": "SVG",
"exportVisiblePng": "PNG",
"exportVisiblePdf": "PDF",
- "exportXlsx": "XLSX (Full)",
- "exportXlsxAdmin": "XLSX (Admin)"
+ "exportXlsx": "XLSX"
}
},
"status": {
@@ -197,6 +197,9 @@
"export": {
"title": "📄 Export to XLSX Options"
},
+ "oidcAccess": {
+ "title": "OIDC Access"
+ },
"emailReports": {
"title": "📧 Email Reports"
}
@@ -407,7 +410,7 @@
"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."
+ "description": "Choose which fields appear in XLSX exports. Restricted columns require the restricted XLSX permission."
},
"columns": {
"name": "Name",
@@ -426,7 +429,23 @@
"options": {
"show": "Show",
"hide": "Hide",
- "admin": "Show Admin Only"
+ "admin": "Restricted"
+ }
+ },
+ "oidcAccess": {
+ "groups": {
+ "label": "Configured security groups",
+ "description": "Group object IDs are configured through environment variables and cannot be changed here.",
+ "reader": "Reader group",
+ "privileged": "Privileged group",
+ "admin": "Admin group"
+ },
+ "permissions": {
+ "label": "Privileged group permissions",
+ "description": "Choose which elevated capabilities are granted to the privileged group. Admin group members always have all capabilities.",
+ "reports": "Reports and report exports",
+ "sync": "Manual data sync",
+ "restrictedXlsx": "Restricted XLSX columns"
}
},
"data": {
diff --git a/static/styles.css b/static/styles.css
index 2333799..7cd54ca 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -519,6 +519,36 @@ body.fullscreen-mode .org-chart-container {
flex-wrap: wrap;
}
+.primary-search-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1 1 380px;
+ min-width: 0;
+}
+
+.primary-search-controls .search-wrapper {
+ max-width: none;
+}
+
+.find-me-btn {
+ padding: 7px 12px;
+ border: 1px solid #0078d4;
+ border-radius: 4px;
+ background: #ffffff;
+ color: #005a9e;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.find-me-btn:hover,
+.find-me-btn:focus-visible {
+ background: #e8f3fb;
+ border-color: #005a9e;
+}
+
.search-wrapper {
position: relative;
flex: 1 1 320px;
@@ -983,6 +1013,11 @@ body.fullscreen-mode .org-chart-container {
gap: 15px;
align-items: stretch;
}
+
+ .primary-search-controls {
+ flex: 0 0 auto;
+ width: 100%;
+ }
.top-user-controls {
min-width: auto;
diff --git a/templates/configure.html b/templates/configure.html
index eb4b42d..26fd616 100644
--- a/templates/configure.html
+++ b/templates/configure.html
@@ -360,17 +360,66 @@
📄 Export to XLSX Options
+ {% if auth_type == 'oidc' %}
+
OIDC Access
+
+
+
+
+
Group object IDs are configured through environment variables and cannot be changed here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Choose which elevated capabilities are granted to the privileged group. Admin group members always have all capabilities.
+
+
+ Reports and report exports
+
+
+
+ Manual data sync
+
+
+
+ Restricted XLSX columns
+
+
+
+ {% endif %}
+
-
Choose which fields appear in XLSX exports. “Show Admin Only” keeps the column hidden for regular viewers but includes it when an administrator exports.
+
Choose which fields appear in XLSX exports. Restricted columns require the restricted XLSX permission.