Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0ce6996b | ||
|
|
5e60d17d60 | ||
|
|
20156ffc93 | ||
|
|
c55db48894 | ||
|
|
04efb462af | ||
|
|
b5476228ba | ||
|
|
b60c4a5c47 | ||
|
|
37c9cbfd2c | ||
|
|
5df151724a | ||
|
|
606d8e944b | ||
|
|
9cf23da907 | ||
|
|
1c79ac15b7 | ||
|
|
bbe9c62ff9 | ||
|
|
b77bf70ff5 | ||
|
|
edc820051e | ||
|
|
2260a451a5 | ||
|
|
efbbeb7306 | ||
|
|
54f8848e30 | ||
|
|
8a446509c6 |
No files matched your search
+1
-1
@@ -5,7 +5,7 @@
|
||||
*.json
|
||||
!lang/*.json
|
||||
!keywords/*.json
|
||||
!skus/*.json
|
||||
!classification/*.json
|
||||
!package*.json
|
||||
|
||||
# Be explicit about the most sensitive files
|
||||
|
||||
@@ -11,6 +11,57 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
||||
|
||||
---
|
||||
|
||||
## [1.7.13] — 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Result cards showed an ID instead of the person's name** — cards from scheduled scans of profiles saved in the profile editor showed the account's Microsoft 365 ID (e.g. `38a9797a-e065-…`) where the name should be, and no Elev/Ansat badge. The profile editor saved accounts as bare IDs, and the scan used the ID as the name. The scan now looks such accounts up for their name and role (using the same Elev/Ansat classification as the account list), the profile editor saves the name and role with each account, and existing cards with an ID as their name now show the name and role from the account list — no rescan needed.
|
||||
- **Excel report could show IDs instead of names** — for scans started from ID-only account lists, the report's account-name lookup failed as a whole and fell back to the stored values.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.12] — 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Microsoft 365 scan failed with "404 Not Found" for Google-only accounts** — accounts that exist only in Google Workspace were sent to the Microsoft 365 scan (`…/users/google:<email>/mailFolders`), which Graph cannot resolve. This happened when no Microsoft 365 account was ticked (the scanner then took every ticked account, Google-only ones included), and in scheduled scans whose profile contains Google-only accounts. The Microsoft 365 scan now skips Google-only accounts and reports how many it skipped; the Google Workspace scan still covers them.
|
||||
- **An M365 scan with no M365 accounts selected could scan the whole tenant** — with Google sources ticked and no Microsoft 365 account ticked, the Microsoft 365 part started with an empty account list, which the server treats as "every user". It is now skipped, with a note in the log.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.11] — 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Selecting a profile ticked accounts that aren't in it** — a regression in 1.7.10: applying a profile also ticked every Microsoft 365-only and every Google Workspace-only account in the sidebar, on top of the profile's own accounts. Only accounts present in both M365 and Google Workspace followed the profile correctly. Because a scan uses the ticked accounts, a scan started after selecting a profile in 1.7.10 could cover more accounts than the profile lists. Profiles now tick exactly their own accounts.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.10] — 2026-09-28
|
||||
|
||||
### Changed
|
||||
|
||||
- **The Stop button responds immediately** — clicking **Stop** now switches the button to a disabled **Stopping…** state and logs that the scanner is finishing the current item. Previously nothing changed until that item was done (up to a minute for a large PDF being OCR'd), so the scan appeared to ignore the click.
|
||||
- **Stopping a scheduled scan stops the whole job** — the remaining stages (file sources, Google Workspace) are skipped, the run is recorded as **cancelled** (⏹ in the job history), and no report email or retention run happens for the partial results.
|
||||
- **Refreshing the page no longer replays the log of a finished or stopped scan** — only a scan that is still running is replayed. Results still load from the database as before.
|
||||
- **Repository moved to `git.fejl40.nu`** — clone and security-contact details in the documentation now point to the Gitea repository; security reports go to support@fejl40.nu. Server installs that self-update must have their git `origin` pointed at `https://git.fejl40.nu/Fejl40.nu/GDPRScanner.git`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Pressing Stop looked like the scan restarting** — a stopped scan was never marked as finished, so every page load (and the 4-second reconnect check) replayed its events and brought back the Stop button and progress bar. Stopping while accounts were still being collected also announced a fresh "Scanning…" phase before stopping. For scheduled jobs, the Google stage cleared the stop request and genuinely started a new scan of all Workspace users. All three are fixed.
|
||||
- **Profiles lost their selected accounts** — starting a scan overwrote the selected profile's accounts with whatever was ticked in the sidebar (dropping Google-only users and anyone hidden by the Elev/Ansat filter), and a scan with no profile selected overwrote the *first* profile. Scans no longer change profiles. The profile editor also dropped manually added accounts, and accounts that weren't loaded yet, on the next save, and Google-only accounts weren't ticked when a profile was applied — both fixed.
|
||||
- **Manually added accounts were never scanned** — accounts added in the sidebar were silently left out of every scan, and accounts added in the profile editor were stored in a form Microsoft 365 couldn't use. The profile editor now looks the account up in Microsoft 365, applying a profile adds its manual accounts to the sidebar, and older profiles are resolved automatically at scan time (unknown accounts are reported and skipped, never widened to all users).
|
||||
- **Deleting an Outlook message failed** with "'M365Connector' object has no attribute 'delete_message'" — the method had been lost; restored (moves the message to Deleted Items).
|
||||
- **Results appeared underneath the landing card** — during a scan, results rendered below a still-visible landing/last-scan card until a manual refresh. The card is now hidden as soon as results appear.
|
||||
- **Licence/OU classification data wasn't found** — the SKU and Google OU role files were read from `classification/` but lived elsewhere, so role classification silently used its fallback. The files are now where the code expects them.
|
||||
- **Crashes in the background scan thread are logged** — an unexpected error in a scan used to vanish silently; it is now written to the log and the audit log.
|
||||
|
||||
### Internal
|
||||
|
||||
- Replaced deprecated imports (`fitz` → `pymupdf`, `smbprotocol.query_info` → `smbprotocol.file_info`) ahead of their removal upstream. No behaviour change.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.9] — 2026-06-22
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ very welcome.
|
||||
|
||||
```bash
|
||||
# Clone and set up a virtual environment
|
||||
git clone https://github.com/your-org/gdpr-scanner.git
|
||||
cd gdpr-scanner
|
||||
git clone https://git.fejl40.nu/Fejl40.nu/GDPRScanner.git
|
||||
cd GDPRScanner
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # macOS / Linux
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
@@ -635,8 +635,8 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a pull request.
|
||||
|
||||
```bash
|
||||
# Quick start for contributors
|
||||
git clone https://github.com/your-org/gdpr-scanner.git
|
||||
cd gdpr-scanner
|
||||
git clone https://git.fejl40.nu/Fejl40.nu/GDPRScanner.git
|
||||
cd GDPRScanner
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python gdpr_scanner.py # GDPRScanner on port 5100 (auto-increments if in use)
|
||||
|
||||
+11
-4
@@ -8,9 +8,16 @@ git commit -m "Your message"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
GitHub Actions builds Windows and Linux binaries automatically and updates the
|
||||
**"Latest build (main)"** pre-release at:
|
||||
`https://github.com/<owner>/GDPRScanner/releases/tag/latest`
|
||||
The build workflow (`.github/workflows/build.yml`) builds Windows and Linux
|
||||
binaries and updates the **"Latest build (main)"** pre-release at:
|
||||
`https://git.fejl40.nu/Fejl40.nu/GDPRScanner/releases/tag/latest`
|
||||
|
||||
> **Note:** the repository moved from GitHub to Gitea (`git.fejl40.nu`). The
|
||||
> workflow still publishes with the GitHub-only `softprops/action-gh-release`
|
||||
> action, so releases are **not yet produced on Gitea**. Until the workflow is
|
||||
> ported to Gitea Actions, build and attach binaries manually. Server installs
|
||||
> are unaffected — they self-update from `git.fejl40.nu` via git
|
||||
> (see `update_gdpr.sh`).
|
||||
|
||||
## Versioned release
|
||||
|
||||
@@ -22,7 +29,7 @@ git push origin main
|
||||
git push origin v1.6.14
|
||||
```
|
||||
|
||||
Actions creates a proper GitHub Release named `v1.6.14` with auto-generated
|
||||
Actions creates a proper release named `v1.6.14` with auto-generated
|
||||
release notes and the built binaries attached.
|
||||
|
||||
Append `-beta` or `-rc` to the tag to mark it as a pre-release:
|
||||
|
||||
+2
-3
@@ -12,14 +12,13 @@ We support only the latest release. Please update before reporting a bug.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not file a public GitHub issue for security vulnerabilities.**
|
||||
**Please do not file a public issue for security vulnerabilities.**
|
||||
|
||||
This tool processes sensitive personal data including Danish CPR numbers (national
|
||||
identifiers). Security issues should be reported privately so a fix can be prepared
|
||||
before public disclosure.
|
||||
|
||||
**Report to:** Open a [GitHub Security Advisory](https://github.com/your-org/gdpr-scanner/security/advisories/new)
|
||||
(Settings → Security → Advisories → New draft advisory)
|
||||
**Report to:** [support@fejl40.nu](mailto:support@fejl40.nu) — put "Security" in the subject line.
|
||||
|
||||
Please include:
|
||||
- A description of the vulnerability and its potential impact
|
||||
|
||||
@@ -45,6 +45,42 @@ Six root causes fixed in `scan_engine.py` and `document_scanner.py`:
|
||||
|
||||
## Pending
|
||||
|
||||
### M365 not scanning M365+GWS accounts after the 1.7.12 update — ⏳ awaiting verification
|
||||
**Reported 2026-09-28.** After updating the server to 1.7.12, a scan did not cover the Microsoft 365 sources for accounts shown with the unified **M365 + GWS** pill, even though they were ticked. After running the update again the problem no longer appeared. Status: being tested by the maintainer; reopen with the details below if it comes back.
|
||||
|
||||
**What was verified (against a copy of real profile data, scans intercepted, nothing actually scanned):**
|
||||
- Applying `STAFF-M365-GWS-FULL` and pressing Scan sends `POST /api/scan/start` with all 33 accounts (31 M365+GWS, 2 M365-only), each with its M365 GUID and no `google:` ids; the Google scan gets the 31 Google emails.
|
||||
- Server side, the only 1.7.12 change that filters accounts is `scan_engine._resolve_scan_users()`, which drops ids starting with `google:`. M365+GWS accounts are sent with their M365 GUID, so they pass. No other change since 1.7.9 filters accounts.
|
||||
|
||||
**Likely cause: a half-applied update** (not proven — no logs from the first update):
|
||||
- `update_gdpr.sh` only restarts the process if a systemd unit named `gdprscanner` exists; otherwise it prints "restart GDPRScanner manually" and the old Python keeps running against the new static JS.
|
||||
- A browser tab left open through the update keeps running the old JS modules until reloaded.
|
||||
- Both update paths auto-stash local edits (`git stash`), silently removing any hotfix made directly on the server.
|
||||
|
||||
**Checking a server after an update** (install dir `/opt/gdprscanner`):
|
||||
```bash
|
||||
git -C /opt/gdprscanner log --oneline -1 # expect the release commit on origin/main
|
||||
cat /opt/gdprscanner/VERSION # expect the released version
|
||||
git -C /opt/gdprscanner status --short # expect no output
|
||||
git -C /opt/gdprscanner stash list # any entries = local changes the updater set aside
|
||||
systemctl show gdprscanner -p ActiveEnterTimestamp # must be after the update (CLI/cron updates only)
|
||||
journalctl -u gdprscanner --since "<update time>" --no-pager | grep "\[skus\] Loaded" # one line per app start
|
||||
```
|
||||
The **web-UI update** (Settings → Software update) restarts via `os.execv` in the same process (same PID), so `ActiveEnterTimestamp` does **not** change — use the `[skus] Loaded …` startup line instead: there must be one after the release commit was pushed. (`[ou-roles] Loaded …` lines are logged per account-list load, not per start.)
|
||||
Then hard-refresh the browser (Cmd+Shift+R) before scanning. Also confirm `git -C /opt/gdprscanner remote -v` points at `https://git.fejl40.nu/Fejl40.nu/GDPRScanner.git` (the repo moved from GitHub).
|
||||
|
||||
**If it recurs, collect:** the log from "Starter scanning: …" onward (look for *"Ingen Microsoft 365-konti valgt"*, *"Sprang N konto(er) over, der kun findes i Google Workspace"*, or no Outlook/OneDrive lines at all); how the scan was started (profile / hand-ticked / scheduled job) and which profile; whether the Elev/Ansat filter or search was active; and the output of the checks above.
|
||||
|
||||
**Rollback:** 1.7.11 (`v1.7.11`) doesn't have this symptom but still sends Google-only accounts to Graph (404s). Reverting `b547622` on `main` restores that behaviour.
|
||||
|
||||
**Possible hardening (not started):**
|
||||
1. The page notices when the server's `VERSION` has changed since it loaded and asks the user to reload.
|
||||
2. `update_gdpr.sh` exits non-zero with a clear warning when it cannot restart the service, instead of only logging it.
|
||||
|
||||
**Size:** Small · **Priority:** High until verified
|
||||
|
||||
---
|
||||
|
||||
### #15 — Scan profiles ✅
|
||||
Named, reusable scan configurations. Full spec in SUGGESTIONS.md §15.
|
||||
**Size:** Large · **Priority:** High
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
# GDPR Scanner — Brugermanual
|
||||
|
||||
Version 1.7.9
|
||||
Version 1.7.13
|
||||
|
||||
---
|
||||
|
||||
@@ -185,7 +185,7 @@ En statuslinje viser:
|
||||
|
||||
Resultater vises i hovedområdet efterhånden som de findes — du behøver ikke vente på, at scanningen er færdig, før du begynder at gennemgå dem.
|
||||
|
||||
Klik på **Stop** for at afbryde. Et kontrolpunkt gemmes automatisk, så du kan fortsætte senere.
|
||||
Klik på **Stop** for at afbryde. Knappen skifter til **Stopper…**, mens scanneren gør det element færdigt, den er i gang med (det kan tage et øjeblik, f.eks. for en stor PDF), hvorefter scanningen stopper. Et kontrolpunkt gemmes automatisk, så du kan fortsætte senere. Stopper du en planlagt scanning, stoppes hele jobbet — resterende kilder springes over, og der sendes ingen rapport-e-mail for de delvise resultater.
|
||||
|
||||
### 4.5 Genoptag en afbrudt scanning
|
||||
|
||||
@@ -382,7 +382,7 @@ Konfigurer venstre panel præcis som du ønsker det — herunder hvilke M365-kil
|
||||
|
||||
Klik på profil-rullemenuen i topbjælken og vælg en profil. Alle indstillinger i venstre panel — kilder, konti, indstillinger og datofilter — indlæses på én gang. Venstre panel viser derefter din aktive tilstand, og du kan justere hvad som helst, inden du scanner.
|
||||
|
||||
En **Ryd**-knap vises ved siden af rullemenuen, når en profil er valgt. Klik på den for at rydde profiletiketten uden at ændre indstillingerne i venstre panel. Det er nyttigt, når du vil køre en engangsscan uden at overskrive en gemt profil.
|
||||
En **Ryd**-knap vises ved siden af rullemenuen, når en profil er valgt. Klik på den for at rydde profiletiketten uden at ændre indstillingerne i venstre panel. En scanning ændrer aldrig en gemt profil — profiler ændres kun, når du gemmer dem med **Gem** eller i profileditoren.
|
||||
|
||||
### Administrer profiler
|
||||
|
||||
@@ -661,7 +661,7 @@ Brug **Art.30**-knappen til at eksportere artikel 30-rapporten. Det er et Word-d
|
||||
Scanneren klassificerer brugere som ansatte eller elever ud fra deres Microsoft 365-licenstype eller Google Workspace-organisationsenhed. Du kan bruge dette filter i kontolisten til at begrænse en scanning til kun ansatte, kun elever eller en bestemt person. Det er nyttigt, fordi reglerne for behandling af elevdata — særligt for børn under 15 år — adskiller sig fra reglerne for medarbejderdata i henhold til databeskyttelsesloven.
|
||||
|
||||
**Hvordan tilføjer jeg en konto, der ikke er på listen?**
|
||||
I kontoafsnittet i venstre panel er der et felt **+ Tilføj konto manuelt**. Indtast e-mailadressen eller UPN'en, og den tilføjes til den aktuelle sessions kontoliste.
|
||||
I kontoafsnittet i venstre panel er der et felt **+ Tilføj konto manuelt**. Indtast e-mailadressen eller UPN'en, og den tilføjes til den aktuelle sessions kontoliste. Vil du beholde kontoen fast, så tilføj den i profileditoren med **+ Tilføj konto** — scanneren slår den op i Microsoft 365, og kontoen medtages, hver gang profilen bruges, også i planlagte scanninger. Manuelt tilføjede konti skal findes i Microsoft 365.
|
||||
|
||||
**Kører scanneren? Jeg kan ikke se en statuslinje.**
|
||||
Tjek aktivitetsloggen nederst på skærmen. Hvis en scanning kører, vises der beskeder her. Hvis du ikke ser noget, er scanningen muligvis afsluttet eller ikke startet. Kontrollér også, at du har valgt mindst én kilde og mindst én konto.
|
||||
@@ -683,4 +683,4 @@ For en typisk skole- eller kommunescanning er omkostningen ubetydelig — Claude
|
||||
|
||||
---
|
||||
|
||||
*GDPR Scanner v1.7.9 — teknisk opsætning og konfiguration: se README.md*
|
||||
*GDPR Scanner v1.7.13 — teknisk opsætning og konfiguration: se README.md*
|
||||
@@ -1,6 +1,6 @@
|
||||
# GDPR Scanner — User Manual
|
||||
|
||||
Version 1.7.9
|
||||
Version 1.7.13
|
||||
|
||||
---
|
||||
|
||||
@@ -185,7 +185,7 @@ A progress bar appears showing:
|
||||
|
||||
Results appear in the main area as they are found — you do not need to wait for the scan to finish before reviewing them.
|
||||
|
||||
To stop a scan, click **Stop**. A checkpoint is saved automatically so you can resume later.
|
||||
To stop a scan, click **Stop**. The button changes to **Stopping…** while the scanner finishes the item it is working on (this can take a moment, e.g. for a large PDF), then the scan stops. A checkpoint is saved automatically so you can resume later. Stopping a scheduled scan stops the whole job — any remaining sources are skipped, and no report email is sent for the partial results.
|
||||
|
||||
### 4.5 Resuming an Interrupted Scan
|
||||
|
||||
@@ -382,7 +382,7 @@ Configure the sidebar exactly as you want it — including which M365 sources, G
|
||||
|
||||
Click the profile dropdown in the top bar and select a profile. All sidebar settings — sources, accounts, options, and date filter — are loaded at once. The sidebar then shows your live state and you can adjust anything before scanning.
|
||||
|
||||
A **Clear** button appears next to the dropdown after you select a profile. Click it to clear the profile label without changing the sidebar settings. This is useful when you want to run a one-off scan without overwriting a saved profile.
|
||||
A **Clear** button appears next to the dropdown after you select a profile. Click it to clear the profile label without changing the sidebar settings. Running a scan never changes a saved profile — profiles only change when you save them with **Save** or in the profile editor.
|
||||
|
||||
### Managing profiles
|
||||
|
||||
@@ -661,7 +661,7 @@ Use the **Art.30** button to export the Article 30 report. It is a Word document
|
||||
The scanner classifies users as staff (Ansat) or students (Elev) based on their Microsoft 365 licence type or Google Workspace organisational unit. You can use this filter in the accounts list to restrict a scan to only staff, only students, or a specific individual. This is useful because the rules for processing student data — especially for children under 15 — differ from staff data under Databeskyttelsesloven.
|
||||
|
||||
**How do I add an account that is not in the list?**
|
||||
In the accounts section of the sidebar, there is an **+ Tilføj konto manuelt** (Add account manually) field. Enter the email address or UPN and it will be added to the current session's account list.
|
||||
In the accounts section of the sidebar, there is an **+ Tilføj konto manuelt** (Add account manually) field. Enter the email address or UPN and it will be added to the current session's account list. To keep an account permanently, add it in the profile editor with **+ Tilføj konto** — the scanner looks it up in Microsoft 365, and the account is included whenever that profile is used, including in scheduled scans. Manually added accounts must exist in Microsoft 365.
|
||||
|
||||
**Is the scanner running? I cannot see a progress bar.**
|
||||
Check the activity log at the bottom of the screen. If a scan is running it will show messages there. If you see nothing, the scan may have completed or not started. Also check that you have at least one source ticked and at least one account selected.
|
||||
@@ -683,4 +683,4 @@ For a typical school or municipality scan the cost is negligible — Claude Haik
|
||||
|
||||
---
|
||||
|
||||
*GDPR Scanner v1.7.9 — for technical setup and configuration see README.md*
|
||||
*GDPR Scanner v1.7.13 — for technical setup and configuration see README.md*
|
||||
+2
-2
@@ -106,7 +106,7 @@ except ImportError:
|
||||
MASK_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import fitz as _fitz # PyMuPDF — for secure (sanitised) PDF redaction
|
||||
import pymupdf as _fitz # PyMuPDF — for secure (sanitised) PDF redaction
|
||||
PYMUPDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYMUPDF_AVAILABLE = False
|
||||
@@ -2727,7 +2727,7 @@ def count_faces_in_file(path, poppler_path=None, neighbors: int = 4) -> int:
|
||||
|
||||
elif ext == ".pdf":
|
||||
if PYMUPDF_AVAILABLE:
|
||||
import fitz as _fitz
|
||||
import pymupdf as _fitz
|
||||
doc = _fitz.open(str(path))
|
||||
for page_idx in range(min(5, len(doc))):
|
||||
pix = doc[page_idx].get_pixmap(dpi=100)
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ try:
|
||||
FileAttributes, FilePipePrinterAccessMask, ShareAccess,
|
||||
ImpersonationLevel,
|
||||
)
|
||||
from smbprotocol.query_info import FileDirectoryInformation
|
||||
from smbprotocol.file_info import FileDirectoryInformation
|
||||
SMB_OK = True
|
||||
except ImportError:
|
||||
SMB_OK = False
|
||||
|
||||
+8
-11
@@ -744,9 +744,13 @@ def _build_article30_docx() -> tuple[bytes, str]:
|
||||
# user_ids are stored in the options JSON column
|
||||
opts_json = json.loads(row["options"] or "{}") if row else {}
|
||||
for u in opts_json.get("user_ids", []):
|
||||
# Scheduled scans of editor-saved profiles stored bare id
|
||||
# strings; .get() on those raised and dropped the whole map.
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
uid = u.get("id", "")
|
||||
name = u.get("displayName", "")
|
||||
if uid and name:
|
||||
if uid and name and name != uid:
|
||||
_acct_map[uid] = name
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1527,16 +1531,9 @@ def local_ip():
|
||||
def scan_stream():
|
||||
q = queue.Queue(maxsize=512)
|
||||
_sse_queues.append(q)
|
||||
# Filter replay buffer: only include events from the current scan
|
||||
# (avoids replaying stale events from a previous scan)
|
||||
replay_scan_id = _sse_mod._current_scan_id
|
||||
buf = []
|
||||
if replay_scan_id:
|
||||
for msg in list(_sse_buffer):
|
||||
if f'"scan_id": "{replay_scan_id}"' in msg:
|
||||
buf.append(msg)
|
||||
else:
|
||||
buf = list(_sse_buffer)
|
||||
# Replay only the running scan's events, never those of a finished or
|
||||
# stopped scan (see sse.replay_messages)
|
||||
buf = _sse_mod.replay_messages()
|
||||
def generate():
|
||||
try:
|
||||
yield ": connected\n\n"
|
||||
|
||||
+10
-2
@@ -31,6 +31,8 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── google-auth / google-api-python-client ────────────────────────────────────
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
@@ -93,15 +95,21 @@ _MAX_EXPORT_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
_OU_ROLES_PATH = Path(__file__).parent / "classification" / "google_ou_roles.json"
|
||||
|
||||
def _load_ou_roles() -> tuple[list, list]:
|
||||
"""Load student/staff OU prefix lists from skus/google_ou_roles.json.
|
||||
"""Load student/staff OU prefix lists from classification/google_ou_roles.json.
|
||||
Returns (student_prefixes, staff_prefixes) — both lowercased."""
|
||||
try:
|
||||
import json as _j
|
||||
data = _j.loads(_OU_ROLES_PATH.read_text(encoding="utf-8"))
|
||||
students = [p.lower() for p in data.get("student_ou_prefixes", [])]
|
||||
staff = [p.lower() for p in data.get("staff_ou_prefixes", [])]
|
||||
logger.info("[ou-roles] Loaded %d student + %d staff OU prefixes from classification/google_ou_roles.json",
|
||||
len(students), len(staff))
|
||||
return students, staff
|
||||
except Exception:
|
||||
except FileNotFoundError:
|
||||
logger.warning("[ou-roles] classification/google_ou_roles.json not found — using hardcoded fallback prefixes only")
|
||||
return ["/elever", "/students"], ["/personale", "/staff", "/lærere", "/ansatte"]
|
||||
except Exception as e:
|
||||
logger.error("[ou-roles] Failed to load classification/google_ou_roles.json: %s — using hardcoded fallback prefixes only", e)
|
||||
return ["/elever", "/students"], ["/personale", "/staff", "/lærere", "/ansatte"]
|
||||
|
||||
def classify_ou_role(org_unit_path: str) -> str:
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
"m365_admin_note": "Viser kun din konto. For at liste alle brugere skal en administrator give samtykke til <strong>User.Read.All</strong> i Azure Portal, eller tilføj konti manuelt nedenfor.",
|
||||
"m365_btn_scan": "Scan",
|
||||
"m365_btn_stop": "Stop",
|
||||
"m365_no_m365_accounts": "Ingen Microsoft 365-konti valgt — Microsoft 365-kilderne springes over.",
|
||||
"m365_btn_stopping": "Stopper…",
|
||||
"m365_scan_stopping": "Stopper scanning — færdiggør det aktuelle element…",
|
||||
"m365_scan_stopped": "Scanning stoppet.",
|
||||
"m365_sched_stopped": "stoppet",
|
||||
"m365_pill_flagged": "markerede",
|
||||
"m365_pill_scanned": "scannet",
|
||||
"m365_filter_all_sources": "Alle kilder",
|
||||
@@ -513,6 +518,7 @@
|
||||
"m365_filter_special_only": "Art. 9 følsomme data",
|
||||
"m365_badge_special": "Art.9",
|
||||
"m365_phase_scanning": "Scanner…",
|
||||
"m365_skipped_google_accounts": "Sprang {n} konto(er) over, der kun findes i Google Workspace — ikke i Microsoft 365",
|
||||
"a30_special_cat": "Særlige kategorier (artikel 9)",
|
||||
"a30_special_cat_note": "Disse elementer indeholder helbredsoplysninger, straffeoplysninger, biometriske data, religiøse, etniske, fagforeningsmæssige, politiske eller seksuelle oplysninger. Et eksplicit retsgrundlag (art. 9, stk. 2) og eventuelt en konsekvensanalyse (art. 35) er påkrævet.",
|
||||
"a30_col_special": "Art. 9",
|
||||
@@ -532,6 +538,9 @@
|
||||
"m365_profile_save_tip": "Gem aktuelle indstillinger som profil",
|
||||
"m365_profile_save_prompt": "Profilnavn:",
|
||||
"m365_profile_applied": "Profil indlæst",
|
||||
"m365_profile_acct_manual": "Manuel",
|
||||
"m365_profile_acct_unresolved": "Ikke indlæst",
|
||||
"m365_profile_manual_not_found": "Manuel konto ikke fundet i M365",
|
||||
"m365_profile_saved": "Profil gemt",
|
||||
"m365_profile_manage_btn": "Profiler",
|
||||
"m365_profile_clear_btn": "Ryd",
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
"m365_admin_note": "Es wird nur Ihr Konto angezeigt. Um alle Benutzer aufzulisten, muss ein Administrator <strong>User.Read.All</strong> im Azure Portal genehmigen oder Konten manuell unten hinzufügen.",
|
||||
"m365_btn_scan": "Scannen",
|
||||
"m365_btn_stop": "Stop",
|
||||
"m365_no_m365_accounts": "Keine Microsoft 365-Konten ausgewählt — Microsoft 365-Quellen werden übersprungen.",
|
||||
"m365_btn_stopping": "Wird gestoppt…",
|
||||
"m365_scan_stopping": "Scan wird gestoppt — aktuelles Element wird abgeschlossen…",
|
||||
"m365_scan_stopped": "Scan gestoppt.",
|
||||
"m365_sched_stopped": "gestoppt",
|
||||
"m365_pill_flagged": "markiert",
|
||||
"m365_pill_scanned": "gescannt",
|
||||
"m365_filter_all_sources": "Alle Quellen",
|
||||
@@ -513,6 +518,7 @@
|
||||
"m365_filter_special_only": "Art. 9 besondere Kategorien",
|
||||
"m365_badge_special": "Art.9",
|
||||
"m365_phase_scanning": "Scanne…",
|
||||
"m365_skipped_google_accounts": "{n} reine Google Workspace-Konten übersprungen — nicht in Microsoft 365",
|
||||
"a30_special_cat": "Besondere Kategorien (Artikel 9)",
|
||||
"a30_special_cat_note": "Diese Elemente enthalten Gesundheits-, Straf-, biometrische, religiöse, ethnische, gewerkschaftliche, politische oder sexuelle Daten. Eine ausdrückliche Rechtsgrundlage (Art. 9 Abs. 2) und ggf. eine DSFA (Art. 35) sind erforderlich.",
|
||||
"a30_col_special": "Art. 9",
|
||||
@@ -532,6 +538,9 @@
|
||||
"m365_profile_save_tip": "Aktuelle Einstellungen als Profil speichern",
|
||||
"m365_profile_save_prompt": "Profilname:",
|
||||
"m365_profile_applied": "Profil geladen",
|
||||
"m365_profile_acct_manual": "Manuell",
|
||||
"m365_profile_acct_unresolved": "Nicht geladen",
|
||||
"m365_profile_manual_not_found": "Manuelles Konto in M365 nicht gefunden",
|
||||
"m365_profile_saved": "Profil gespeichert",
|
||||
"m365_profile_manage_btn": "Profile",
|
||||
"m365_profile_clear_btn": "Löschen",
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
"m365_admin_note": "Only showing your account. To list all users, an admin must grant <strong>User.Read.All</strong> consent in Azure Portal, or add accounts manually below.",
|
||||
"m365_btn_scan": "Scan",
|
||||
"m365_btn_stop": "Stop",
|
||||
"m365_no_m365_accounts": "No Microsoft 365 accounts selected — skipping the Microsoft 365 sources.",
|
||||
"m365_btn_stopping": "Stopping…",
|
||||
"m365_scan_stopping": "Stopping scan — finishing the current item…",
|
||||
"m365_scan_stopped": "Scan stopped.",
|
||||
"m365_sched_stopped": "stopped",
|
||||
"m365_pill_flagged": "flagged",
|
||||
"m365_pill_scanned": "scanned",
|
||||
"m365_filter_all_sources": "All sources",
|
||||
@@ -513,6 +518,7 @@
|
||||
"m365_filter_special_only": "Art. 9 special category",
|
||||
"m365_badge_special": "Art.9",
|
||||
"m365_phase_scanning": "Scanning…",
|
||||
"m365_skipped_google_accounts": "Skipped {n} Google Workspace-only account(s) — not in Microsoft 365",
|
||||
"a30_special_cat": "Art. 9 special category items",
|
||||
"a30_special_cat_note": "These items contain health, criminal, biometric, religious, ethnic, trade union, political, or sexual orientation data. An explicit legal basis (Art. 9(2)) and possibly a DPIA (Art. 35) is required.",
|
||||
"a30_col_special": "Art. 9",
|
||||
@@ -532,6 +538,9 @@
|
||||
"m365_profile_save_tip": "Save current settings as a profile",
|
||||
"m365_profile_save_prompt": "Profile name:",
|
||||
"m365_profile_applied": "Profile loaded",
|
||||
"m365_profile_acct_manual": "Manual",
|
||||
"m365_profile_acct_unresolved": "Not loaded",
|
||||
"m365_profile_manual_not_found": "Manual account not found in M365",
|
||||
"m365_profile_saved": "Profile saved",
|
||||
"m365_profile_manage_btn": "Profiles",
|
||||
"m365_profile_clear_btn": "Clear",
|
||||
|
||||
@@ -224,6 +224,11 @@ class M365Connector:
|
||||
return {"displayName": "App (service account)", "id": "", "mail": ""}
|
||||
return self._get("/me")
|
||||
|
||||
def lookup_user(self, upn: str) -> dict:
|
||||
"""Look up one directory user by UPN or email (requires User.Read.All)."""
|
||||
return self._get(f"/users/{upn}",
|
||||
{"$select": "id,displayName,mail,userPrincipalName,assignedLicenses"})
|
||||
|
||||
def list_users(self, top: int = 999) -> list:
|
||||
"""List licensed domain users in the tenant (requires User.Read.All).
|
||||
|
||||
@@ -552,6 +557,8 @@ class M365Connector:
|
||||
r.raise_for_status()
|
||||
return True # 204 No Content = success
|
||||
raise _requests.exceptions.RetryError(f"Gave up after {self._MAX_RETRIES} attempts: {url}")
|
||||
|
||||
def delete_message(self, user_id: str, message_id: str) -> bool:
|
||||
"""Move an email to Deleted Items (soft delete)."""
|
||||
base = "/me" if (not user_id or user_id == "me") else f"/users/{user_id}"
|
||||
try:
|
||||
@@ -1150,6 +1157,29 @@ class M365Connector:
|
||||
break
|
||||
return result
|
||||
|
||||
def build_role_sku_map(self, users: list) -> dict:
|
||||
"""The {skuId: skuPartNumber} map classify_user_role() needs.
|
||||
|
||||
Tenant SKUs (get_subscribed_skus) merged with a sample of per-user
|
||||
licenseDetails — the tenant call may only return the admin's own SKUs.
|
||||
Shared by GET /api/users and the scan engine so both classify roles
|
||||
the same way. Never raises; an empty map means ID-only classification.
|
||||
"""
|
||||
try:
|
||||
sku_map = self.get_subscribed_skus()
|
||||
except Exception:
|
||||
sku_map = {}
|
||||
try:
|
||||
per_user = self.build_sku_map_from_users(users)
|
||||
if per_user:
|
||||
added = len(set(per_user) - set(sku_map))
|
||||
sku_map.update(per_user)
|
||||
if added:
|
||||
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
|
||||
except Exception:
|
||||
pass
|
||||
return sku_map
|
||||
|
||||
def classify_user_role(self, assigned_licenses: list,
|
||||
sku_map: dict) -> str:
|
||||
"""Return 'student', 'staff', or 'other' based on assigned O365 licenses.
|
||||
|
||||
+3
-1
@@ -99,6 +99,7 @@ Optional AI-powered NER replacing spaCy. Activated via `config.json` keys `claud
|
||||
- **`apply_update()` never restarts itself** — callers decide. Tests patch `_schedule_restart`; the auto-update thread calls `_restart_self()` directly.
|
||||
- **Auto-update thread** — `start_auto_update_thread()` called from `gdpr_scanner.py` `__main__`. Hourly tick, applies at most once per 24 h when `config.json["auto_update"]` is true; skips (and retries next tick) while a scan runs.
|
||||
- **`update_gdpr.sh`** — standalone CLI/cron equivalent of the same logic; keep stash/ff-only/requirements behaviour in sync.
|
||||
- **Half-applied updates look like code bugs** — `update_gdpr.sh` restarts only if a `gdprscanner` systemd unit exists (else the old Python keeps running under the new static JS), open browser tabs keep old JS modules until reloaded, and both paths auto-stash local edits. Before debugging a post-update regression, verify the server state (HEAD, `VERSION`, clean `git status`, `git stash list`, service start time after the update) — see the "M365 not scanning M365+GWS accounts after the 1.7.12 update" entry in `TODO.md`.
|
||||
|
||||
## Viewer mode — routes/viewer.py
|
||||
|
||||
@@ -115,5 +116,6 @@ Optional AI-powered NER replacing spaCy. Activated via `config.json` keys `claud
|
||||
## Gotchas
|
||||
|
||||
- **`_load_settings()` return** — does NOT include `file_sources`. Returns only: sources, user_ids, options, retention_years, fiscal_year_end, email_to.
|
||||
- **`_save_settings()` clobbers profile fields** — called on every M365 scan start with only M365 sources/user_ids/options. The fix in `app_config.py` preserves `google_sources` and `file_sources` and rebuilds `sources` as `m365_src + google_src + file_src`. Do not simplify away this merge logic.
|
||||
- **`POST /api/scan/start` must not write profiles** — it used to call `_save_settings()` with the sidebar state, which overwrote the active profile's `user_ids` (dropping Google-only users and role-filtered-out users) and, with no active profile, clobbered `profiles[0]`. Profiles change only via `/api/profiles/save`. `profile_id` in the start payload is audit-only. Do not re-add the call.
|
||||
- **`_save_settings()` merge logic** — still used by `/api/settings/save`. It preserves `google_sources` and `file_sources` and rebuilds `sources` as `m365_src + google_src + file_src`. Do not simplify away this merge logic.
|
||||
- **`loadLastScanSummary()` timing** — must only be called after the first `/api/scan/status` poll resolves (inside `_sseWatchdog` in `results.js`, guarded by `_initialStatusChecked`). Calling it on `DOMContentLoaded` shows a stale "no results" card during a live scan after a hard refresh.
|
||||
+6
-5
@@ -103,17 +103,18 @@ def scan_start():
|
||||
return jsonify({"error": "scan already running"}), 409
|
||||
options = request.get_json() or {}
|
||||
state._scan_abort.clear()
|
||||
# Profiles are loaders: a scan must never write the sidebar state back into
|
||||
# a profile (it overwrote user_ids, dropped Google-only users, and hit
|
||||
# profiles[0] when no profile was active). profile_id is audit-only here.
|
||||
profile_id = options.pop("profile_id", None)
|
||||
_save_settings({
|
||||
"sources": options.get("sources", []),
|
||||
"user_ids": options.get("user_ids", []),
|
||||
"options": options.get("options", {}),
|
||||
}, profile_id=profile_id)
|
||||
def _run():
|
||||
from scan_engine import run_scan
|
||||
try:
|
||||
run_scan(options)
|
||||
_maybe_send_auto_email()
|
||||
except Exception:
|
||||
_log.exception("[scan] background scan thread crashed")
|
||||
_audit("scan_error", "background scan thread crashed", ip="")
|
||||
finally:
|
||||
state._scan_lock.release()
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
+4
-20
@@ -24,25 +24,9 @@ def get_users():
|
||||
out = []
|
||||
seen = set()
|
||||
|
||||
# Build SKU map for role classification.
|
||||
# get_subscribed_skus() tries /subscribedSkus → /me/licenseDetails.
|
||||
# Then always merge per-user licenseDetails on top — this ensures we
|
||||
# have skuPartNumbers for every distinct SKU in the tenant, not just
|
||||
# the admin's own license (which is all /me/licenseDetails returns).
|
||||
try:
|
||||
sku_map = state.connector.get_subscribed_skus()
|
||||
except Exception:
|
||||
sku_map = {}
|
||||
|
||||
try:
|
||||
per_user = state.connector.build_sku_map_from_users(users)
|
||||
if per_user:
|
||||
added = len(set(per_user) - set(sku_map))
|
||||
sku_map.update(per_user)
|
||||
if added:
|
||||
logger.info("[skus] merged %d additional SKU(s) from per-user licenseDetails", added)
|
||||
except Exception:
|
||||
pass
|
||||
# SKU map for role classification — shared with the scan engine so the
|
||||
# account list and scanned cards classify Elev/Ansat identically.
|
||||
sku_map = state.connector.build_role_sku_map(users)
|
||||
|
||||
# Load any manual role overrides set by the admin
|
||||
_role_overrides = _load_role_overrides()
|
||||
@@ -176,7 +160,7 @@ def lookup_user():
|
||||
if not upn:
|
||||
return jsonify({"error": "upn required"}), 400
|
||||
try:
|
||||
data = state.connector._get(f"/users/{upn}", {"$select": "id,displayName,mail,userPrincipalName"})
|
||||
data = state.connector.lookup_user(upn)
|
||||
_email = data.get("mail") or data.get("userPrincipalName", upn)
|
||||
return jsonify({
|
||||
"id": data["id"],
|
||||
|
||||
+113
-17
@@ -41,6 +41,95 @@ except ImportError:
|
||||
|
||||
from routes import state as _state
|
||||
|
||||
_MANUAL_PREFIX = "manual:"
|
||||
_GOOGLE_PREFIX = "google:"
|
||||
|
||||
|
||||
def _resolve_scan_users(conn, user_ids: list[dict]) -> list[dict]:
|
||||
"""Turn the requested accounts into Graph user ids for the M365 scan.
|
||||
|
||||
- "manual:<email>" (older profile-editor saves, or saves made while M365
|
||||
was not connected) is looked up to get the directory id.
|
||||
- "google:<email>" is a Google-Workspace-only account: it has no M365
|
||||
mailbox/drive and Graph 404s on it. Profiles and older UI selections can
|
||||
contain them, so they are dropped here (the Google scan covers them).
|
||||
Accounts that can't be used are reported and skipped — never widened to
|
||||
"all users".
|
||||
"""
|
||||
resolved, seen = [], set()
|
||||
skipped_google = []
|
||||
for u in user_ids:
|
||||
uid = u.get("id", "")
|
||||
if uid.startswith(_GOOGLE_PREFIX):
|
||||
skipped_google.append(uid[len(_GOOGLE_PREFIX):])
|
||||
continue
|
||||
if uid.startswith(_MANUAL_PREFIX):
|
||||
upn = uid[len(_MANUAL_PREFIX):]
|
||||
try:
|
||||
data = conn.lookup_user(upn)
|
||||
_email = data.get("mail") or data.get("userPrincipalName", upn)
|
||||
u = {**u, "id": data["id"],
|
||||
"displayName": _resolve_display_name(data.get("displayName", ""), _email, upn)}
|
||||
except Exception as e:
|
||||
logger.warning("[run_scan] manual account %r not found: %s", upn, e)
|
||||
broadcast("scan_error", {"file": upn, "error": f"Manual account not found in M365: {e}"})
|
||||
continue
|
||||
if u["id"] in seen:
|
||||
continue
|
||||
seen.add(u["id"])
|
||||
resolved.append(u)
|
||||
if skipped_google:
|
||||
logger.info("[run_scan] skipped %d Google-only account(s) in M365 scan", len(skipped_google))
|
||||
broadcast("scan_phase", {"phase": LANG.get(
|
||||
"m365_skipped_google_accounts",
|
||||
"Skipped {n} Google Workspace-only account(s) — not in Microsoft 365").replace(
|
||||
"{n}", str(len(skipped_google)))})
|
||||
return resolved
|
||||
|
||||
|
||||
def _enrich_bare_users(conn, user_ids: list[dict]) -> list[dict]:
|
||||
"""Fill in displayName and userRole for accounts given only by id.
|
||||
|
||||
Profiles saved by the profile editor (and scheduled scans of them) pass
|
||||
plain id strings, which run_scan normalises to displayName=<id>,
|
||||
userRole="other" — so their cards showed the GUID as the name and no
|
||||
Elev/Ansat badge. Look those accounts up in the directory once, using the
|
||||
same role classification as GET /api/users. Accounts that can't be looked
|
||||
up keep what they had.
|
||||
"""
|
||||
bare = {id(u) for u in user_ids
|
||||
if not u.get("displayName") or u.get("displayName") == u.get("id")}
|
||||
if not bare:
|
||||
return user_ids
|
||||
|
||||
directory: dict[str, dict] = {}
|
||||
sku_map: dict = {}
|
||||
try:
|
||||
users = conn.list_users()
|
||||
directory = {u["id"]: u for u in users if u.get("id")}
|
||||
sku_map = conn.build_role_sku_map(users)
|
||||
except Exception as e: # e.g. delegated mode without User.Read.All
|
||||
logger.info("[run_scan] directory list unavailable for name lookup: %s", e)
|
||||
|
||||
enriched = []
|
||||
for u in user_ids:
|
||||
if id(u) in bare:
|
||||
uid = u.get("id", "")
|
||||
d = directory.get(uid)
|
||||
if d is None:
|
||||
try:
|
||||
d = conn.lookup_user(uid) # not in the licensed-member list
|
||||
except Exception:
|
||||
d = None
|
||||
if d:
|
||||
_email = d.get("mail") or d.get("userPrincipalName", "")
|
||||
u = {**u, "displayName": _resolve_display_name(d.get("displayName", ""), _email, uid)}
|
||||
if d.get("assignedLicenses") is not None and u.get("userRole", "other") == "other":
|
||||
u["userRole"] = conn.classify_user_role(d.get("assignedLicenses", []), sku_map)
|
||||
enriched.append(u)
|
||||
return enriched
|
||||
|
||||
|
||||
def _get_scan_abort():
|
||||
return _state._scan_abort
|
||||
|
||||
@@ -522,12 +611,6 @@ def run_scan(options: dict):
|
||||
if rem < 3600: return f"{int(rem/60)}m"
|
||||
return f"{int(rem/3600)}h"
|
||||
|
||||
def _check_abort():
|
||||
if _state._scan_abort.is_set():
|
||||
broadcast("scan_cancelled", {"completed": completed})
|
||||
return True
|
||||
return False
|
||||
|
||||
def _broadcast_card(item_meta: dict, cprs: list, pii_counts: dict | None = None):
|
||||
card = {
|
||||
"id": item_meta.get("id", ""),
|
||||
@@ -645,6 +728,8 @@ def run_scan(options: dict):
|
||||
me_info.get("displayName", ""),
|
||||
me_info.get("mail") or me_info.get("userPrincipalName", "me"))}]
|
||||
else:
|
||||
user_ids = _resolve_scan_users(conn, user_ids)
|
||||
user_ids = _enrich_bare_users(conn, user_ids)
|
||||
sample = user_ids[0] if user_ids else None
|
||||
logger.info("[run_scan] user_ids: %d entries, type=%s, sample=%s",
|
||||
len(user_ids), type(user_ids).__name__, sample)
|
||||
@@ -1057,6 +1142,26 @@ def run_scan(options: dict):
|
||||
if scanned_ids:
|
||||
work_items = [(k, m, f) for k, m, f in work_items if m.get("id", "") not in scanned_ids]
|
||||
|
||||
def _finish_aborted(done: int) -> None:
|
||||
"""Stop requested: checkpoint, finalise the DB scan, announce it."""
|
||||
# Save checkpoint so scan can be resumed later
|
||||
_save_checkpoint(ck_key, scanned_ids, _state.flagged_items, _state.scan_meta)
|
||||
# Finalise the DB scan record so items found before the stop stay
|
||||
# visible — the early return otherwise skips finish_scan below,
|
||||
# stranding them (invisible to get_session_items / get_open_items).
|
||||
if _db and _db_scan_id:
|
||||
try:
|
||||
_db.finish_scan(_db_scan_id, done)
|
||||
except Exception as _e:
|
||||
logger.error("[db] finish_scan (aborted) failed: %s", _e)
|
||||
broadcast("scan_cancelled", {"completed": done - resumed_count})
|
||||
|
||||
# Stop pressed during collection: the per-user loops just break out, so
|
||||
# stop here — announcing scan_start/"Scanning…" made Stop look like a restart.
|
||||
if _state._scan_abort.is_set():
|
||||
_finish_aborted(resumed_count)
|
||||
return
|
||||
|
||||
total = len(work_items)
|
||||
broadcast("scan_start", {
|
||||
"total": total + resumed_count,
|
||||
@@ -1075,17 +1180,8 @@ def run_scan(options: dict):
|
||||
_items_since_save = 0
|
||||
idx = -1
|
||||
while _work_q:
|
||||
if _check_abort():
|
||||
# Save checkpoint so scan can be resumed later
|
||||
_save_checkpoint(ck_key, scanned_ids, _state.flagged_items, _state.scan_meta)
|
||||
# Finalise the DB scan record so items found before the stop stay
|
||||
# visible — this early return otherwise skips finish_scan below,
|
||||
# stranding them (invisible to get_session_items / get_open_items).
|
||||
if _db and _db_scan_id:
|
||||
try:
|
||||
_db.finish_scan(_db_scan_id, resumed_count + idx + 1)
|
||||
except Exception as _e:
|
||||
logger.error("[db] finish_scan (aborted) failed: %s", _e)
|
||||
if _state._scan_abort.is_set():
|
||||
_finish_aborted(resumed_count + idx + 1)
|
||||
return
|
||||
idx += 1
|
||||
kind, meta, _ = _work_q.popleft() # releases this item from the deque immediately
|
||||
|
||||
+27
-12
@@ -351,6 +351,9 @@ class ScanScheduler:
|
||||
)
|
||||
_fs_extra = {k: _profile_opts[k] for k in _FS_OPT_KEYS if k in _profile_opts}
|
||||
for fs in options.get("file_sources", []):
|
||||
# Stop pressed during an earlier stage — don't start more stages
|
||||
if _m._scan_abort.is_set():
|
||||
break
|
||||
# Resolve string IDs to full source dicts
|
||||
if isinstance(fs, str):
|
||||
fs = _all_file_sources.get(fs, {"path": fs, "label": fs})
|
||||
@@ -374,7 +377,12 @@ class ScanScheduler:
|
||||
# Legacy profiles store everything in sources[]
|
||||
google_sources = [s for s in options.get("sources", [])
|
||||
if s in ("gmail", "gdrive")]
|
||||
if google_sources and state.google_connector:
|
||||
# POST /api/scan/stop sets both abort events. _scan_abort was cleared
|
||||
# when this job started, so if it is set now the user pressed Stop
|
||||
# during an earlier stage — the Google stage must not start, and its
|
||||
# abort event must not be cleared (that turned Stop into a restart).
|
||||
stopped = _m._scan_abort.is_set()
|
||||
if google_sources and state.google_connector and not stopped:
|
||||
from routes.google_scan import (
|
||||
_run_google_scan as _rgs,
|
||||
_scan_lock as _gsl,
|
||||
@@ -383,12 +391,15 @@ class ScanScheduler:
|
||||
if _gsl.acquire(blocking=False):
|
||||
try:
|
||||
_gsa.clear()
|
||||
logger.info("[scheduler] Starting Google scan — sources=%s", google_sources)
|
||||
_rgs({
|
||||
"sources": google_sources,
|
||||
"user_emails": [], # empty → scan all workspace users
|
||||
"options": options.get("options", {}),
|
||||
})
|
||||
# Stop sets _scan_abort first, so re-check after clearing:
|
||||
# a Stop landing in between must still win.
|
||||
if not _m._scan_abort.is_set():
|
||||
logger.info("[scheduler] Starting Google scan — sources=%s", google_sources)
|
||||
_rgs({
|
||||
"sources": google_sources,
|
||||
"user_emails": [], # empty → scan all workspace users
|
||||
"options": options.get("options", {}),
|
||||
})
|
||||
except Exception as _ge:
|
||||
import traceback as _tb3
|
||||
logger.error("[scheduler] Google scan error: %s\n%s", _ge, _tb3.format_exc())
|
||||
@@ -399,18 +410,21 @@ class ScanScheduler:
|
||||
|
||||
run["flagged"] = len(_m.flagged_items)
|
||||
run["scanned"] = _m.scan_meta.get("total_scanned", 0)
|
||||
run["status"] = "completed"
|
||||
logger.info("[scheduler] Job '%s' completed — %d flagged, %d scanned",
|
||||
job_cfg.get("name", ""), run["flagged"], run["scanned"])
|
||||
stopped = _m._scan_abort.is_set()
|
||||
run["status"] = "cancelled" if stopped else "completed"
|
||||
logger.info("[scheduler] Job '%s' %s — %d flagged, %d scanned",
|
||||
job_cfg.get("name", ""), run["status"], run["flagged"], run["scanned"])
|
||||
|
||||
if job_cfg.get("auto_email") and state.flagged_items:
|
||||
# A stopped job's results are partial — don't email them or apply
|
||||
# retention based on them.
|
||||
if not stopped and job_cfg.get("auto_email") and state.flagged_items:
|
||||
try:
|
||||
self._send_email_report(job_cfg)
|
||||
run["emailed"] = 1
|
||||
except Exception as e:
|
||||
run["error"] = f"Scan OK, email failed: {e}"
|
||||
|
||||
if job_cfg.get("auto_retention") and job_cfg.get("retention_years"):
|
||||
if not stopped and job_cfg.get("auto_retention") and job_cfg.get("retention_years"):
|
||||
try:
|
||||
self._run_retention(job_cfg)
|
||||
except Exception as e:
|
||||
@@ -420,6 +434,7 @@ class ScanScheduler:
|
||||
_m.broadcast("scheduler_done", {
|
||||
"flagged": run["flagged"], "scanned": run["scanned"],
|
||||
"emailed": run["emailed"], "job_name": job_cfg.get("name", ""),
|
||||
"cancelled": stopped,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -19,6 +19,29 @@ logger = logging.getLogger(__name__)
|
||||
_sse_queues: list = []
|
||||
_sse_buffer: deque = deque(maxlen=500)
|
||||
_current_scan_id: str = ""
|
||||
# Scans that have ended (done / stopped). Their events stay in the buffer but
|
||||
# are never replayed: replaying a stopped scan re-showed the Stop button and
|
||||
# progress bar on every reconnect, so Stop looked like a restart.
|
||||
_finished_scan_ids: deque = deque(maxlen=20)
|
||||
|
||||
# Events that end the scan identified by _current_scan_id.
|
||||
# scan_done: M365 finished; scan_cancelled: M365 stopped; file_scan_done:
|
||||
# file scan finished or stopped. (Google scans don't set _current_scan_id.)
|
||||
_TERMINAL_EVENTS = frozenset({"scan_done", "scan_cancelled", "file_scan_done"})
|
||||
|
||||
def replay_messages() -> list[str]:
|
||||
"""Buffered messages to replay to a newly connected browser.
|
||||
|
||||
While a scan runs, only that scan's events. Otherwise everything except
|
||||
events of scans that have already ended (done or stopped).
|
||||
"""
|
||||
buf = list(_sse_buffer)
|
||||
if _current_scan_id:
|
||||
tag = f'"scan_id": "{_current_scan_id}"'
|
||||
return [m for m in buf if tag in m]
|
||||
finished = [f'"scan_id": "{sid}"' for sid in _finished_scan_ids]
|
||||
return [m for m in buf if not any(tag in m for tag in finished)]
|
||||
|
||||
|
||||
def broadcast(event: str, data: dict):
|
||||
global _current_scan_id
|
||||
@@ -31,8 +54,9 @@ def broadcast(event: str, data: dict):
|
||||
q.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
# Clear scan_id after scan_done so replay knows the scan is finished
|
||||
if event == "scan_done" and _current_scan_id:
|
||||
# Clear scan_id when the scan ends so replay knows it is finished
|
||||
if event in _TERMINAL_EVENTS and _current_scan_id:
|
||||
_finished_scan_ids.append(_current_scan_id)
|
||||
_current_scan_id = ""
|
||||
# When no browser is watching (e.g. scheduled scan), log key events
|
||||
if not _sse_queues:
|
||||
|
||||
+15
-2
@@ -34,6 +34,11 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
|
||||
- **Do not close `S.es` in `scan_done` if other scans are still running** — M365 (`scan_done`), Google (`google_scan_done`), and File (`file_scan_done`) each emit their own done event. Close `S.es` only when all concurrent scans have finished: `scan_done` checks `!S._googleScanRunning && !S._fileScanRunning`; `google_scan_done` checks `!S._m365ScanRunning && !S._fileScanRunning`; `file_scan_done` checks `!S._m365ScanRunning && !S._googleScanRunning`.
|
||||
- **Scheduled scans** — `S._userStartedScan` is false for scheduler-triggered runs, so SSE is never closed and future scheduler events continue to arrive.
|
||||
- **Two separate abort events** — `state._scan_abort` (M365 + file) and `state._google_scan_abort` (Google). `POST /api/scan/stop` sets **both**. `_check_abort()` inside `_run_google_scan` must use the module-level `_scan_abort` alias (`= state._google_scan_abort`), not `gdpr_scanner._scan_abort`.
|
||||
- **Stop must never look like a restart** — four rules, each fixing a way Stop used to appear to restart the scan:
|
||||
- `sse.replay_messages()` never replays a scan that has ended. `scan_done`, `scan_cancelled` and `file_scan_done` move `_current_scan_id` into `_finished_scan_ids`. Do not go back to clearing the id only on `scan_done`: stopped M365 scans and all file scans stayed "current", and every reconnect (page load, watchdog) replayed them, bringing back the Stop button and progress bar.
|
||||
- The `scan_cancelled` handler clears `S._m365ScanRunning` and, like `scan_done`, keeps the Stop button and SSE only while a Google or file scan is still running. `scheduler_done` and `scheduler_error` clear all three running flags.
|
||||
- The Stop button goes through `_setStopBtnDisplay(display, fresh)`; do not set `stopBtn.style.display` directly. `stopScan()` switches it to a disabled "Stopping…" state. Hiding it, or passing `fresh=true` (from `startScan()` and `scheduler_started`), resets it.
|
||||
- Server side: `run_scan` checks for Stop straight after collection, before `scan_start`. The scheduler checks `_scan_abort` between stages and never clears the Google abort flag after a Stop. A stopped job is recorded as `cancelled`, with no email or retention.
|
||||
- **`_check_abort()` emits `google_scan_done`, not `scan_cancelled`** — `scan_cancelled` unconditionally closes the SSE; `google_scan_done` checks whether other scans are still running before closing.
|
||||
- **`scan_phase` replay sets running flags — handled by `sse_replay_done`** — the `scan_phase` handler sets running flags to `true` whenever all flags are `false` and a source keyword is found in the phase text. On page refresh this fires during SSE replay of a completed scan, temporarily making the scan appear running. The `sse_replay_done` handler retries `loadHistorySession(null)` if no scan is running and `S._historyRefScanId` is still `null` after replay. Do not remove either the flag-setting logic or the retry.
|
||||
- **Google Drive uses a lazy generator, not `list()`** — `iter_drive_files()` iterated directly so `_check_abort()` fires between items. Wrapping in `list()` blocks the thread for the entire enumeration.
|
||||
@@ -48,10 +53,11 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
|
||||
- **Cache invalidation** — `invalidateHistoryCache()` clears `_sessions` and `_latestRefScanId`. All three `*_done` SSE handlers call `window.invalidateHistoryCache?.()`.
|
||||
- **Re-scan diff** — items present in the previous session but absent from the current one are tagged `_resolved: true`, rendered with `.card-resolved` and a green ✓ badge, and NOT added to `S.flaggedData` (grid-only, cannot be bulk-selected or exported).
|
||||
- **Mode transitions** — `startScan()` calls `window.exitHistoryMode?.()` before clearing the grid.
|
||||
- **`renderGrid(files)` hides the landing cards** — whenever `files.length > 0` it hides `#emptyState` and `#lastScanSummary` and shows `#grid`. This is centralised here because the live `scan_file_flagged` handler (`scan.js`) shows the grid but does NOT clear those panels, so results would render *underneath* a still-visible landing/last-scan card until a manual refresh. Do not move this hiding back into individual callers — every render path (live SSE, `loadOpenItems`, history, filters) must clear the landing. The empty case (`files.length === 0`) is left untouched so callers still control the empty/landing state.
|
||||
|
||||
## Card user/group badge — results.js
|
||||
|
||||
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
|
||||
- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. An `account_name` equal to `account_id` (a GUID stored by older scheduled scans of editor-saved profiles) counts as missing, and a role other than `student`/`staff` is filled from the same `S._allUsers` match. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper.
|
||||
|
||||
## CPR cross-referencing — results.js
|
||||
|
||||
@@ -79,7 +85,14 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
|
||||
|
||||
- **`scheduler.js` strings must use `t()`** — frequency labels, "Next", "Running...", "Disabled", empty-job text, and empty-history text all have translation keys. Do not hard-code English strings in `schedLoad()` or `schedRenderJobs()`.
|
||||
- **Scheduler UI — `schedToggleReportOnly()`** — dims the Profile row, shows/hides `#schedReportOnlyHint`, and forces `#schedAutoEmail` checked. Called from the checkbox `onchange` handler and at the start of `schedAddJob()` / `schedEditJob()`.
|
||||
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked.
|
||||
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked. Saved IDs not in `S._allUsers` (`manual:<email>`, users not loaded yet, users gone from the directory) are rendered by `_unresolvedAccountRows()` as checked rows so the next save keeps them — do not go back to building rows from `S._allUsers` alone (that silently dropped them on save). `user_ids: "all"` is kept as `"all"` when saved before users have loaded. The editor saves each account as `{id, displayName, userRole}` (plus `email`/`manual` for manual accounts), the same shape as 💾 — not a bare id string: scheduled scans pass profile accounts straight to `run_scan`, and bare ids produced GUID-named cards with no role badge. Server side, `scan_engine._enrich_bare_users()` names any bare ids that remain (older profiles) using `conn.build_role_sku_map()`, the same role classification as `GET /api/users`.
|
||||
- **Manual accounts must be scannable** — they aren't in the `/api/users` directory list, so:
|
||||
- `getSelectedUsers()` treats a missing `platform` as `'m365'`. Sidebar `addUserManually()` users historically had no platform and were silently excluded from every scan. Do not go back to a strict `u.platform === 'm365'` check.
|
||||
- The profile editor's "+ Tilføj konto" looks the email up via `/api/users/lookup` and stores `{id: <directory id>, displayName, email, manual: true}`. It falls back to `"manual:<email>"` only when the lookup fails. Checkboxes with `data-manual` save as objects; everything else saves as plain id strings.
|
||||
- `_applyProfile()` → `_addProfileManualUsers()` pushes manual accounts into `S._allUsers` (`manual: true`, `platform: 'm365'`). Legacy `manual:<email>` entries are looked up and the pending ID is rewritten to the resolved one. Without this the account can never be ticked in the sidebar, so it's never scanned.
|
||||
- `_userListFinal()` keys off `S._m365UsersLoaded`, not `S._allUsers.length`, because manual users can be pushed before the directory loads.
|
||||
- Server side, `scan_engine._resolve_scan_users()` resolves any remaining `manual:<email>` (e.g. from scheduled scans) via `conn.lookup_user()`, and drops `google:<email>` (Google-only accounts, which Graph 404s on). It skips and reports failures and never widens to "all users".
|
||||
- **Pending profile users wait for the Google merge** — `_applyPendingProfileUsers()` keeps `window._pendingProfileUserIds` until every ID is matched or `_userListFinal()` (users loaded, and Google either not connected or `S._googleUsersMerged`). `_mergeGoogleUsers()` re-applies in its `finally`. A saved `google:<email>` also matches an M365 user merged to `platform: 'both'` via `u.googleEmail`. Do not clear the pending IDs unconditionally — Google-only users would stay unticked.
|
||||
- **Date presets** — stored as `years * 365` (integer days). Do not use `* 365.25`.
|
||||
- **`copyTokenLink` is async** — called from `onclick` as fire-and-forget. Do not make it synchronous.
|
||||
- **Escape scan-derived strings with `esc()`** — `results.js` defines `esc()` (escapes `& < > " '`). Every value that originates from scanned content (`f.name`, `f.account_name`, `f.folder`, `f.source`, `f.modified`, `label`, image `alt`, and the same fields on `item`/related rows) must pass through `esc()` before going into `innerHTML` or a `title=`/`alt=` attribute. These are attacker-influenceable (e.g. a file named with markup), so an unescaped interpolation is stored XSS — including in shared read-only viewer sessions. Numeric counts (`cpr_count`, `size_kb`) don't need it. When embedding an object in an `onclick` payload, also `.replace(/"/g,'"')` the `JSON.stringify(...)`.
|
||||
+137
-18
@@ -215,6 +215,7 @@ function _applyProfile(profile) {
|
||||
}
|
||||
} else if (Array.isArray(profile.user_ids) && profile.user_ids.length) {
|
||||
window._pendingProfileUserIds = profile.user_ids.map(u => u.id || u);
|
||||
_addProfileManualUsers(profile.user_ids);
|
||||
_applyPendingProfileUsers();
|
||||
} else if (Array.isArray(profile.user_ids) && profile.user_ids.length === 0) {
|
||||
// Explicitly empty list — deselect everyone so previous sidebar state doesn't persist
|
||||
@@ -225,13 +226,74 @@ function _applyProfile(profile) {
|
||||
log(t('m365_profile_applied', 'Profile loaded') + ': ' + profile.name);
|
||||
}
|
||||
|
||||
// Google-only users (id "google:<email>") arrive later than M365 users, via the
|
||||
// un-awaited _mergeGoogleUsers(). Keep the pending IDs until every one is
|
||||
// matched or the user list is final, else those users stay unticked.
|
||||
function _userListFinal() {
|
||||
return S._m365UsersLoaded && (!window._googleConnected || S._googleUsersMerged);
|
||||
}
|
||||
|
||||
function _applyPendingProfileUsers() {
|
||||
const ids = window._pendingProfileUserIds;
|
||||
if (!ids || !ids.length || !S._allUsers.length) return;
|
||||
// Select only the users listed in the profile
|
||||
S._allUsers.forEach(u => { u.selected = ids.includes(u.id); });
|
||||
const wanted = new Set(ids);
|
||||
// A saved google:<email> may since have merged onto an M365 user (platform 'both')
|
||||
// Must return a real boolean: the sidebar and getSelectedUsers() treat any
|
||||
// selected value other than `false` as ticked, so an `undefined` here (users
|
||||
// without googleEmail) ticked — and scanned — every M365-only/GWS-only user.
|
||||
const matches = u => wanted.has(u.id) || !!(u.googleEmail && wanted.has('google:' + u.googleEmail));
|
||||
S._allUsers.forEach(u => { u.selected = matches(u); });
|
||||
renderAccountList();
|
||||
window._pendingProfileUserIds = null;
|
||||
const found = S._allUsers.filter(matches).length;
|
||||
if (found >= wanted.size || _userListFinal()) window._pendingProfileUserIds = null;
|
||||
}
|
||||
|
||||
function _isManualEntry(u) {
|
||||
return typeof u === 'object' ? !!u.manual : String(u).startsWith('manual:');
|
||||
}
|
||||
|
||||
// Manual accounts aren't in the /api/users directory list, so they must be added
|
||||
// to S._allUsers (as sidebar manual users) or they can never be selected/scanned.
|
||||
// Legacy "manual:<email>" entries are looked up to get their directory id.
|
||||
function _addProfileManualUsers(entries) {
|
||||
entries.filter(_isManualEntry).forEach(async function(u) {
|
||||
if (typeof u === 'object' && !String(u.id).startsWith('manual:')) {
|
||||
if (!S._allUsers.some(x => x.id === u.id)) {
|
||||
S._allUsers.push({id: u.id, displayName: u.displayName || u.email || u.id,
|
||||
email: u.email || '', userRole: u.userRole || 'other',
|
||||
platform: 'm365', manual: true, selected: false});
|
||||
}
|
||||
return; // selection is handled by _applyPendingProfileUsers()
|
||||
}
|
||||
const legacyId = u.id || u;
|
||||
try {
|
||||
const email = legacyId.slice('manual:'.length);
|
||||
const d = await _lookupM365User(email);
|
||||
if (!d) {
|
||||
// Not connected / not found — scheduled scans retry the lookup at scan time
|
||||
log(t('m365_profile_manual_not_found', 'Manual account not found in M365') + ': ' + email, 'err');
|
||||
return;
|
||||
}
|
||||
let user = S._allUsers.find(x => x.id === d.id);
|
||||
if (!user) {
|
||||
user = {...d, platform: 'm365', manual: true};
|
||||
S._allUsers.push(user);
|
||||
}
|
||||
user.selected = true;
|
||||
// Pending apply may re-run later — make it select the resolved id, not the legacy one
|
||||
const pend = window._pendingProfileUserIds;
|
||||
if (pend) window._pendingProfileUserIds = pend.map(x => x === legacyId ? d.id : x);
|
||||
renderAccountList();
|
||||
} catch(e) { /* leave for the scan-time fallback */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Returns {id, displayName, email} or null when M365 isn't connected / the user doesn't exist.
|
||||
async function _lookupM365User(upn) {
|
||||
const r = await fetch('/api/users/lookup?upn=' + encodeURIComponent(upn));
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json();
|
||||
return d && d.id ? d : null;
|
||||
}
|
||||
|
||||
async function saveCurrentAsProfile() {
|
||||
@@ -365,19 +427,26 @@ function _openEditorForProfile(profile) {
|
||||
const srcCheck = (id) => allSources.includes(id) ? 'checked' : '';
|
||||
|
||||
// Build account list from S._allUsers
|
||||
const savedIds = new Set((profile.user_ids || []).map(u => u.id || u));
|
||||
// If no saved IDs match current users, treat as all-selected (new profile or users changed)
|
||||
const anyMatch = savedIds.size > 0 && S._allUsers.some(u => savedIds.has(u.id));
|
||||
const savedAll = profile.user_ids === 'all';
|
||||
const savedList = Array.isArray(profile.user_ids) ? profile.user_ids : [];
|
||||
const savedIds = new Set(savedList.map(u => u.id || u));
|
||||
const isSaved = u => savedAll || savedIds.has(u.id)
|
||||
|| !!(u.googleEmail && savedIds.has('google:' + u.googleEmail));
|
||||
// Remember "all" so saving before the user list has loaded doesn't turn it into []
|
||||
window._pmgmtSavedAll = savedAll;
|
||||
const accountRows = S._allUsers.map(u => {
|
||||
// Only check if the user was explicitly saved — default to unchecked like the main window
|
||||
const checked = anyMatch && savedIds.has(u.id) ? 'checked' : '';
|
||||
const checked = isSaved(u) ? 'checked' : '';
|
||||
// data-name lets the save keep the display name with the id (see _pmgmtSaveFullEdit)
|
||||
const manualAttrs = ` data-name="${_esc(u.displayName || '')}"` + (u.manual
|
||||
? ` data-manual="1" data-email="${_esc(u.email || '')}"` : '');
|
||||
const platBadge = u.platform === 'both' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%);color:#1a4a1a;font-weight:500;border:0.5px solid #b5d4b5">M365+GWS</span>'
|
||||
: (u.platform || 'm365') === 'google' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#EAF3DE;color:#3B6D11;font-weight:500">GWS</span>'
|
||||
: '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#E6F1FB;color:#185FA5;font-weight:500">M365</span>';
|
||||
const roleBadge = u.userRole === 'student' ? t('role_student','Elev') : u.userRole === 'staff' ? t('role_staff','Ansat') : t('role_other','Anden');
|
||||
const roleOverrideStyle = u.roleOverride ? 'color:var(--color-text-info);outline:1px solid var(--color-border-info);' : '';
|
||||
return `<label class="pmgmt-acct-row" data-uid="${_esc(u.id)}" data-role="${_esc(u.userRole || 'other')}"><input type="checkbox" ${checked} data-uid="${_esc(u.id)}"><span style="flex:1;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.displayName)}</span>${platBadge}<button type="button" class="pmgmt-role-badge" data-uid="${_esc(u.id)}" onclick="_pmgmtCycleRole(this.getAttribute('data-uid'),event)" style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;border:none;cursor:pointer;${roleOverrideStyle}">${roleBadge}</button></label>`;
|
||||
}).join('');
|
||||
return `<label class="pmgmt-acct-row" data-uid="${_esc(u.id)}" data-role="${_esc(u.userRole || 'other')}"><input type="checkbox" ${checked} data-uid="${_esc(u.id)}"${manualAttrs}><span style="flex:1;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.displayName)}</span>${platBadge}<button type="button" class="pmgmt-role-badge" data-uid="${_esc(u.id)}" onclick="_pmgmtCycleRole(this.getAttribute('data-uid'),event)" style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;border:none;cursor:pointer;${roleOverrideStyle}">${roleBadge}</button></label>`;
|
||||
}).join('') + _unresolvedAccountRows(savedList);
|
||||
|
||||
body.innerHTML = `
|
||||
<div>
|
||||
@@ -464,6 +533,31 @@ function _openEditorForProfile(profile) {
|
||||
_renderEditorSources((profile.sources || []).concat(profile.google_sources || []).concat(profile.file_sources || []));
|
||||
}
|
||||
|
||||
// Saved accounts that aren't in S._allUsers — manually added ("manual:<email>"),
|
||||
// not loaded yet, or gone from the directory. Rendered as checked rows so the
|
||||
// next save keeps them instead of silently dropping them.
|
||||
function _unresolvedAccountRows(savedList) {
|
||||
const known = new Set();
|
||||
S._allUsers.forEach(u => {
|
||||
known.add(u.id);
|
||||
if (u.googleEmail) known.add('google:' + u.googleEmail);
|
||||
});
|
||||
const seen = new Set();
|
||||
return savedList.map(u => {
|
||||
const id = u.id || u;
|
||||
if (!id || known.has(id) || seen.has(id)) return '';
|
||||
seen.add(id);
|
||||
const isManual = _isManualEntry(u);
|
||||
const label = (typeof u === 'object' && u.displayName) || id.replace(/^(manual|google):/, '');
|
||||
const badge = isManual ? t('m365_profile_acct_manual', 'Manual')
|
||||
: t('m365_profile_acct_unresolved', 'Not loaded');
|
||||
const savedName = typeof u === 'object' && u.displayName && u.displayName !== id ? u.displayName : '';
|
||||
const manualAttrs = (savedName ? ` data-name="${_esc(savedName)}"` : '') + (isManual && typeof u === 'object'
|
||||
? ` data-manual="1" data-email="${_esc(u.email || '')}"` : '');
|
||||
return `<label class="pmgmt-acct-row" data-uid="${_esc(id)}" data-role="${_esc((typeof u === 'object' && u.userRole) || 'other')}"><input type="checkbox" checked data-uid="${_esc(id)}"${manualAttrs}><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(id)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(badge)}</span></label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _peSetDate(val) {
|
||||
if (!val) return;
|
||||
const ms = new Date() - new Date(val);
|
||||
@@ -592,16 +686,24 @@ function _pmgmtRoleFilter(role) {
|
||||
_pmgmtFilterAccounts(document.getElementById('pmgmtAcctSearch')?.value || '');
|
||||
}
|
||||
|
||||
function _pmgmtAddManual() {
|
||||
const email = prompt('E-mail adresse:');
|
||||
if (!email || !email.trim()) return;
|
||||
async function _pmgmtAddManual() {
|
||||
const email = (prompt('E-mail adresse:') || '').trim();
|
||||
if (!email) return;
|
||||
const list = document.getElementById('pmgmtAcctList');
|
||||
if (!list) return;
|
||||
const id = 'manual:' + email.trim().toLowerCase();
|
||||
if (list.querySelector(`input[data-uid="${id}"]`)) return; // already exists
|
||||
// Resolve to the directory id so the scan can use it. If M365 isn't reachable
|
||||
// right now, fall back to "manual:<email>" — the scan engine resolves that later.
|
||||
let d = null;
|
||||
try { d = await _lookupM365User(email); } catch(e) { /* offline — use fallback */ }
|
||||
const id = d ? d.id : 'manual:' + email.toLowerCase();
|
||||
const label = d ? (d.displayName || email) : email;
|
||||
const existing = [...list.querySelectorAll('input[data-uid]')].find(cb => cb.dataset.uid === id);
|
||||
if (existing) { existing.checked = true; return; } // already listed — just tick it
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'pmgmt-acct-row';
|
||||
lbl.innerHTML = `<input type="checkbox" checked data-uid="${_esc(id)}"><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(email.trim())}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">Manuel</span>`;
|
||||
lbl.dataset.uid = id;
|
||||
lbl.dataset.role = 'other';
|
||||
lbl.innerHTML = `<input type="checkbox" checked data-uid="${_esc(id)}" data-manual="1" data-name="${_esc(label)}" data-email="${_esc(d ? (d.email || email) : email)}"><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_esc(email)}">${_esc(label)}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${_esc(t('m365_profile_acct_manual', 'Manual'))}</span>`;
|
||||
list.appendChild(lbl);
|
||||
}
|
||||
|
||||
@@ -635,9 +737,22 @@ async function _pmgmtSaveFullEdit() {
|
||||
const effectiveGoogleSources = googleRendered ? googleSources : (profile.google_sources || []);
|
||||
const effectiveFileSources = fileRendered ? fileSources : (profile.file_sources || []);
|
||||
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
|
||||
const user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
|
||||
.map(cb => cb.dataset.uid)
|
||||
.filter(Boolean);
|
||||
let user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
|
||||
.filter(cb => cb.dataset.uid)
|
||||
.map(cb => {
|
||||
// Save name + role with the id (same shape as 💾 / getSelectedUsers()):
|
||||
// scheduled scans pass these straight to the scan, and a bare id became
|
||||
// the card's "name" with no Elev/Ansat badge.
|
||||
const role = cb.closest('label')?.dataset.role || 'other';
|
||||
if (cb.dataset.manual) {
|
||||
return {id: cb.dataset.uid, displayName: cb.dataset.name || '', email: cb.dataset.email || '',
|
||||
userRole: role, manual: true};
|
||||
}
|
||||
return cb.dataset.name ? {id: cb.dataset.uid, displayName: cb.dataset.name, userRole: role}
|
||||
: cb.dataset.uid;
|
||||
});
|
||||
// "all" profile opened before users loaded: there were no rows to tick, so keep "all"
|
||||
if (window._pmgmtSavedAll && !S._allUsers.length && !user_ids.length) user_ids = 'all';
|
||||
const updated = {
|
||||
...profile,
|
||||
name,
|
||||
@@ -769,6 +884,10 @@ window._esc = _esc;
|
||||
window._pmgmtUse = _pmgmtUse;
|
||||
window._pmgmtOpenEditor = _pmgmtOpenEditor;
|
||||
window._openEditorForProfile = _openEditorForProfile;
|
||||
window._unresolvedAccountRows = _unresolvedAccountRows;
|
||||
window._addProfileManualUsers = _addProfileManualUsers;
|
||||
window._lookupM365User = _lookupM365User;
|
||||
window._userListFinal = _userListFinal;
|
||||
window._peSetDate = _peSetDate;
|
||||
window._peSetYear = _peSetYear;
|
||||
window._renderEditorSources = _renderEditorSources;
|
||||
|
||||
+29
-8
@@ -32,21 +32,31 @@ const SOURCE_BADGES = {
|
||||
// email), falling back to an email-style account_id. Returns '' when there is
|
||||
// neither a label nor a role to show.
|
||||
function _accountPill(f) {
|
||||
const roleBadge =
|
||||
f.user_role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
|
||||
f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
|
||||
let role = f.user_role;
|
||||
let label = f.account_name || '';
|
||||
if (!label && f.account_id) {
|
||||
// Scheduled scans of editor-saved profiles stored the account id (a GUID) as
|
||||
// the name, with role "other" — treat that like a missing name.
|
||||
if (label && f.account_id && label === String(f.account_id)) label = '';
|
||||
const needName = !label && f.account_id;
|
||||
const needRole = role !== 'student' && role !== 'staff';
|
||||
if (f.account_id && (needName || needRole)) {
|
||||
const aid = String(f.account_id);
|
||||
const u = (S._allUsers || []).find(function(u) {
|
||||
return u.id === f.account_id ||
|
||||
(u.email && u.email.toLowerCase() === aid.toLowerCase());
|
||||
});
|
||||
if (u) label = u.displayName || '';
|
||||
else if (aid.includes('@')) label = aid; // an email is already human-readable
|
||||
if (u) {
|
||||
if (needName) label = u.displayName || '';
|
||||
if (needRole) role = u.userRole;
|
||||
} else if (needName && aid.includes('@')) {
|
||||
label = aid; // an email is already human-readable
|
||||
}
|
||||
}
|
||||
const roleBadge =
|
||||
role === 'student' ? '<span class="role-badge">' + t('role_student', 'Elev') + '</span>' :
|
||||
role === 'staff' ? '<span class="role-badge">' + t('role_staff', 'Ansat') + '</span>' : '';
|
||||
if (!label && !roleBadge) return '';
|
||||
const title = label || f.user_role || '';
|
||||
const title = label || role || '';
|
||||
return '<span class="account-pill" title="' + esc(title) + '">' + roleBadge + (label ? esc(label) : '') + '</span>';
|
||||
}
|
||||
|
||||
@@ -122,6 +132,17 @@ function renderGrid(files) {
|
||||
const grid = document.getElementById('grid');
|
||||
grid.innerHTML = '';
|
||||
files.forEach(f => appendCard(f));
|
||||
// Whenever results are rendered, the landing/last-scan cards must be hidden —
|
||||
// the live scan_file_flagged path shows the grid but does not clear them, so
|
||||
// results would otherwise appear underneath the still-visible landing page
|
||||
// until a manual refresh. Centralised here so every render path is covered.
|
||||
if (files && files.length) {
|
||||
const es = document.getElementById('emptyState');
|
||||
if (es) es.style.display = 'none';
|
||||
const ls = document.getElementById('lastScanSummary');
|
||||
if (ls) ls.style.display = 'none';
|
||||
if (grid) grid.style.display = S.isListView ? 'block' : 'grid';
|
||||
}
|
||||
_updateBulkBar();
|
||||
updateDispositionStats();
|
||||
}
|
||||
@@ -768,7 +789,7 @@ function _sseWatchdog() {
|
||||
_ensureSSE();
|
||||
if (status.running && !S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
|
||||
document.getElementById('scanBtn').disabled = true;
|
||||
document.getElementById('stopBtn').style.display = 'inline-block';
|
||||
_setStopBtnDisplay('inline-block');
|
||||
// status.running reflects the M365 + file lock; treat as an M365 reconnect
|
||||
S._m365ScanRunning = true; _renderProgressSegments();
|
||||
document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…');
|
||||
|
||||
+65
-24
@@ -317,7 +317,7 @@ function _attachScanListeners(source) {
|
||||
else if (phaseSrc === 'file') { S._fileScanRunning = true; }
|
||||
else { S._m365ScanRunning = true; }
|
||||
document.getElementById('scanBtn').disabled = true;
|
||||
document.getElementById('stopBtn').style.display = 'inline-block';
|
||||
_setStopBtnDisplay('inline-block');
|
||||
_renderProgressSegments();
|
||||
}
|
||||
_setProgressPhase(d.phase);
|
||||
@@ -330,9 +330,9 @@ function _attachScanListeners(source) {
|
||||
: (d.total > 0 ? Math.round((d.index || d.completed || 0) / d.total * 100) : 0);
|
||||
S._srcPct[src] = pct;
|
||||
// If reconnecting mid-scan the running flag may not be set yet — ensure segment exists
|
||||
if (src === 'm365' && !S._m365ScanRunning) { S._m365ScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
|
||||
if (src === 'google' && !S._googleScanRunning) { S._googleScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
|
||||
if (src === 'file' && !S._fileScanRunning) { S._fileScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
|
||||
if (src === 'm365' && !S._m365ScanRunning) { S._m365ScanRunning = true; document.getElementById('scanBtn').disabled = true; _setStopBtnDisplay('inline-block'); _renderProgressSegments(); }
|
||||
if (src === 'google' && !S._googleScanRunning) { S._googleScanRunning = true; document.getElementById('scanBtn').disabled = true; _setStopBtnDisplay('inline-block'); _renderProgressSegments(); }
|
||||
if (src === 'file' && !S._fileScanRunning) { S._fileScanRunning = true; document.getElementById('scanBtn').disabled = true; _setStopBtnDisplay('inline-block'); _renderProgressSegments(); }
|
||||
var fill = document.getElementById('progressFill_' + src);
|
||||
if (fill) fill.style.width = pct + '%';
|
||||
document.getElementById('progressFile').textContent = d.file || '';
|
||||
@@ -368,15 +368,20 @@ function _attachScanListeners(source) {
|
||||
log((d.file ? d.file + ': ' : '') + d.error, 'err');
|
||||
});
|
||||
source.addEventListener('scan_cancelled', function() {
|
||||
if (S._userStartedScan) {
|
||||
// scan_cancelled is the M365 engine's "stopped" event. Clear its running
|
||||
// flag (leaving it set made later replays/status polls treat the stopped
|
||||
// scan as live) and, like scan_done, only tear down the UI once no Google
|
||||
// or file scan is still running.
|
||||
S._m365ScanRunning = false;
|
||||
var _anyRunning = S._googleScanRunning || S._fileScanRunning;
|
||||
if (S._userStartedScan && !_anyRunning) {
|
||||
S._userStartedScan = false;
|
||||
if (S.es) { S.es.close(); S.es = null; }
|
||||
}
|
||||
document.getElementById('scanBtn').disabled = false;
|
||||
document.getElementById('stopBtn').style.display = 'none';
|
||||
_clearProgressBar();
|
||||
setLogLive('');
|
||||
log('Scan stopped.', 'warn');
|
||||
document.getElementById('scanBtn').disabled = _anyRunning;
|
||||
_setStopBtnDisplay(_anyRunning ? 'inline-block' : 'none');
|
||||
if (_anyRunning) { _renderProgressSegments(); } else { _clearProgressBar(); setLogLive(''); }
|
||||
log(t('m365_scan_stopped', 'Scan stopped.'), 'warn');
|
||||
});
|
||||
source.addEventListener('scan_done', function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
@@ -401,7 +406,7 @@ function _attachScanListeners(source) {
|
||||
}
|
||||
if (!_anyRunning) setLogLive('');
|
||||
document.getElementById('scanBtn').disabled = _anyRunning;
|
||||
document.getElementById('stopBtn').style.display = _anyRunning ? 'inline-block' : 'none';
|
||||
_setStopBtnDisplay(_anyRunning ? 'inline-block' : 'none');
|
||||
if (!_anyRunning) _clearProgressBar();
|
||||
document.getElementById('statsSection').style.display = 'block';
|
||||
document.getElementById('statScanned').textContent = d.total_scanned;
|
||||
@@ -437,7 +442,7 @@ function _attachScanListeners(source) {
|
||||
}
|
||||
setLogLive('');
|
||||
document.getElementById('scanBtn').disabled = false;
|
||||
document.getElementById('stopBtn').style.display = 'none';
|
||||
_setStopBtnDisplay('none');
|
||||
_clearProgressBar();
|
||||
document.getElementById('statsSection').style.display = 'block';
|
||||
document.getElementById('statsPill').style.display = 'block';
|
||||
@@ -466,7 +471,7 @@ function _attachScanListeners(source) {
|
||||
}
|
||||
setLogLive('');
|
||||
document.getElementById('scanBtn').disabled = false;
|
||||
document.getElementById('stopBtn').style.display = 'none';
|
||||
_setStopBtnDisplay('none');
|
||||
_clearProgressBar();
|
||||
document.getElementById('statsSection').style.display = 'block';
|
||||
document.getElementById('statsPill').style.display = 'block';
|
||||
@@ -502,7 +507,7 @@ function _attachSchedulerListeners(source) {
|
||||
log('\uD83D\uDD50 ' + t('m365_sched_title','Scheduled scan') + ': ' + (d.job_name||'') + '\u2026');
|
||||
// Show progress UI so scan_phase / scan_progress events are visible
|
||||
document.getElementById('scanBtn').disabled = true;
|
||||
document.getElementById('stopBtn').style.display = 'inline-block';
|
||||
_setStopBtnDisplay('inline-block', true);
|
||||
S._srcPct = { m365: 0, google: 0, file: 0 }; S._m365ScanRunning = true; _renderProgressSegments();
|
||||
_setProgressPhase((d.job_name||'') + '\u2026');
|
||||
document.getElementById('progressFile').textContent = '';
|
||||
@@ -512,25 +517,32 @@ function _attachSchedulerListeners(source) {
|
||||
// scheduler_started was missed (e.g. browser reconnected mid-scan)
|
||||
console.log('[SSE] scan_start received');
|
||||
document.getElementById('scanBtn').disabled = true;
|
||||
document.getElementById('stopBtn').style.display = 'inline-block';
|
||||
_setStopBtnDisplay('inline-block');
|
||||
// Ensure at least the M365 segment is rendered (scan_start is M365-only)
|
||||
if (!S._m365ScanRunning) { S._m365ScanRunning = true; _renderProgressSegments(); }
|
||||
});
|
||||
source.addEventListener('scheduler_done', function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
console.log('[SSE] scheduler_done received:', d);
|
||||
// The job's stages are all over — scheduler_started set the M365 flag
|
||||
S._m365ScanRunning = S._googleScanRunning = S._fileScanRunning = false;
|
||||
document.getElementById('scanBtn').disabled = false;
|
||||
document.getElementById('stopBtn').style.display = 'none';
|
||||
_setStopBtnDisplay('none');
|
||||
_clearProgressBar();
|
||||
log('\u2713 ' + t('m365_sched_title','Scheduled scan') + ' ' + (d.job_name||'') + ' \u2014 ' + (d.flagged||0) + ' flagged', 'ok');
|
||||
if (d.cancelled) {
|
||||
log('\u23f9 ' + t('m365_sched_title','Scheduled scan') + ' ' + (d.job_name||'') + ' \u2014 ' + t('m365_sched_stopped','stopped') + ' (' + (d.flagged||0) + ' flagged)', 'warn');
|
||||
} else {
|
||||
log('\u2713 ' + t('m365_sched_title','Scheduled scan') + ' ' + (d.job_name||'') + ' \u2014 ' + (d.flagged||0) + ' flagged', 'ok');
|
||||
}
|
||||
markOverdueCards();
|
||||
loadTrend();
|
||||
});
|
||||
source.addEventListener('scheduler_error', function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
console.log('[SSE] scheduler_error received:', d);
|
||||
S._m365ScanRunning = S._googleScanRunning = S._fileScanRunning = false;
|
||||
document.getElementById('scanBtn').disabled = false;
|
||||
document.getElementById('stopBtn').style.display = 'none';
|
||||
_setStopBtnDisplay('none');
|
||||
_clearProgressBar();
|
||||
log('\u26A0 ' + t('m365_sched_title','Scheduled scan') + ' failed: ' + (d.error||''), 'err');
|
||||
});
|
||||
@@ -557,14 +569,20 @@ function startScan(resume) {
|
||||
document.getElementById('resumeBanner').style.display = 'none';
|
||||
document.getElementById('logPanel').innerHTML = '<div class="log-line log-live" id="logLive" style="display:none"></div>';
|
||||
try { sessionStorage.removeItem(_LOG_SESSION_KEY); } catch(e) {}
|
||||
S._m365ScanRunning = sources.length > 0;
|
||||
// The M365 scan needs explicit accounts — the server treats an empty list as
|
||||
// "every user in the tenant". With only Google accounts ticked, skip it.
|
||||
const runM365 = sources.length > 0 && user_ids.length > 0;
|
||||
if (sources.length && !runM365) {
|
||||
log(t('m365_no_m365_accounts', 'No Microsoft 365 accounts selected — skipping the Microsoft 365 sources.'), 'warn');
|
||||
}
|
||||
S._m365ScanRunning = runM365;
|
||||
S._googleScanRunning = googleSources.length > 0;
|
||||
S._fileScanRunning = fileSources.length > 0;
|
||||
S._srcPct = { m365: 0, google: 0, file: 0 };
|
||||
S._progressCurrentUser = '';
|
||||
_renderProgressSegments();
|
||||
document.getElementById('scanBtn').disabled = true;
|
||||
document.getElementById('stopBtn').style.display = 'inline-block';
|
||||
_setStopBtnDisplay('inline-block', true);
|
||||
// progress segments rendered by _renderProgressSegments() called above
|
||||
document.getElementById('progressFile').textContent = '';
|
||||
_setProgressPhase(t('scan_preparing', 'Preparing…'));
|
||||
@@ -594,15 +612,15 @@ function startScan(resume) {
|
||||
if (label === 'google') S._googleScanRunning = false;
|
||||
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
|
||||
document.getElementById('scanBtn').disabled = false;
|
||||
document.getElementById('stopBtn').style.display = 'none';
|
||||
_setStopBtnDisplay('none');
|
||||
if (S.es) { S.es.close(); S.es = null; }
|
||||
S._userStartedScan = false;
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// Fire M365 scan if any M365 sources are selected
|
||||
if (sources.length > 0) {
|
||||
// Fire M365 scan if any M365 sources and accounts are selected
|
||||
if (runM365) {
|
||||
fetch('/api/scan/start', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({sources, user_ids, options, resume: !!resume,
|
||||
@@ -664,8 +682,30 @@ function startScan(resume) {
|
||||
|
||||
}
|
||||
|
||||
// Show/hide the Stop button. Hiding — or passing fresh=true when a new scan
|
||||
// starts — resets it from its "Stopping…" state, so a new scan always gets a
|
||||
// normal, clickable Stop button even if a previous scan never reported its end.
|
||||
function _setStopBtnDisplay(display, fresh) {
|
||||
const btn = document.getElementById('stopBtn');
|
||||
if (!btn) return;
|
||||
btn.style.display = display;
|
||||
if (display === 'none' || fresh) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('m365_btn_stop', 'Stop');
|
||||
}
|
||||
}
|
||||
|
||||
function stopScan() {
|
||||
fetch('/api/scan/stop', {method:'POST'});
|
||||
// Immediate feedback: the scan only notices the stop between items, which
|
||||
// can take a while (e.g. OCR on a large PDF). Stays "Stopping…" until the
|
||||
// scan's done/stopped event hides the button.
|
||||
const btn = document.getElementById('stopBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('m365_btn_stopping', 'Stopping…'); }
|
||||
log(t('m365_scan_stopping', 'Stopping scan — finishing the current item…'), 'warn');
|
||||
fetch('/api/scan/stop', {method:'POST'}).catch(function(e) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = t('m365_btn_stop', 'Stop'); }
|
||||
log('Stop failed: ' + e, 'err');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Trend sparkline (#7) ──────────────────────────────────────────────────────
|
||||
@@ -795,6 +835,7 @@ window._attachScanListeners = _attachScanListeners;
|
||||
window._attachSchedulerListeners = _attachSchedulerListeners;
|
||||
window.startScan = startScan;
|
||||
window.stopScan = stopScan;
|
||||
window._setStopBtnDisplay = _setStopBtnDisplay;
|
||||
window.drawSparkline = drawSparkline;
|
||||
window.loadTrend = loadTrend;
|
||||
window.updateStats = updateStats;
|
||||
@@ -242,7 +242,8 @@ function schedLoadHistory() {
|
||||
var html = '';
|
||||
runs.forEach(function(r) {
|
||||
var ts = r.started_at ? new Date(r.started_at * 1000).toLocaleString() : '-';
|
||||
var icon = r.status === 'completed' ? '\u2713' : r.status === 'failed' ? '\u2716' : '\u23f3';
|
||||
var icon = r.status === 'completed' ? '\u2713' : r.status === 'failed' ? '\u2716'
|
||||
: r.status === 'cancelled' ? '\u23f9' : '\u23f3';
|
||||
var jname = r.job_name ? '<strong>' + _esc(r.job_name) + '</strong> - ' : '';
|
||||
html += icon + ' ' + jname + ts + ' - ' + (r.flagged||0) + ' flagged';
|
||||
if (r.emailed) html += ' \u2709';
|
||||
|
||||
@@ -19,6 +19,8 @@ export const S = {
|
||||
_progressCurrentUser: '',
|
||||
// Users
|
||||
_allUsers: [],
|
||||
_m365UsersLoaded: false, // true once loadUsers() has fetched the directory
|
||||
_googleUsersMerged: false, // true once _mergeGoogleUsers() has added Google users
|
||||
// Auth
|
||||
_currentAppMode: null,
|
||||
// Profiles
|
||||
|
||||
+25
-9
@@ -28,6 +28,7 @@ async function loadUsers() {
|
||||
u.selected = prevSelected.has(u.id) ? prevSelected.get(u.id) : false;
|
||||
});
|
||||
S._allUsers = [...fetched, ...toAdd];
|
||||
S._m365UsersLoaded = true;
|
||||
// Apply deferred "select all" from a profile chosen before users loaded
|
||||
if (window._pendingProfileAllUsers) {
|
||||
S._allUsers.forEach(u => { u.selected = true; });
|
||||
@@ -107,6 +108,12 @@ async function _mergeGoogleUsers() {
|
||||
S._allUsers = S._allUsers.concat(googleUsers);
|
||||
renderAccountList();
|
||||
} catch(e) { /* Google users unavailable */ }
|
||||
finally {
|
||||
// Google users are now as complete as they'll get (even on error/early
|
||||
// return) — finish a profile apply that was waiting on google:<email> IDs.
|
||||
S._googleUsersMerged = true;
|
||||
_applyPendingProfileUsers();
|
||||
}
|
||||
}
|
||||
|
||||
let _activeRoleFilter = ''; // '' = all, 'staff', 'student'
|
||||
@@ -415,7 +422,7 @@ async function addUserManually() {
|
||||
const d = await r.json();
|
||||
if (d.error) { alert('User not found: ' + d.error); return; }
|
||||
if (S._allUsers.find(u => u.id === d.id)) { alert('User already in list.'); return; }
|
||||
S._allUsers.push({...d, manual: true});
|
||||
S._allUsers.push({...d, platform: 'm365', manual: true});
|
||||
input.value = '';
|
||||
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
|
||||
} catch(e) {
|
||||
@@ -441,21 +448,30 @@ function selectAllAccounts(checked) {
|
||||
|
||||
function getSelectedUsers() {
|
||||
// Only return M365 users — Google users are handled separately via selectedGoogleEmails
|
||||
let selected = S._allUsers.filter(u => u.selected !== false && (u.platform === 'm365' || u.platform === 'both'));
|
||||
// Manually added users may have no platform — they are M365 lookups, so treat missing as 'm365'
|
||||
let selected = S._allUsers.filter(u => u.selected !== false && ['m365', 'both'].includes(u.platform || 'm365'));
|
||||
// Respect the active role filter — hidden users must not sneak into the scan
|
||||
// even if they were checked before the filter was applied.
|
||||
if (_activeRoleFilter) {
|
||||
selected = selected.filter(u => (u.userRole || 'other') === _activeRoleFilter);
|
||||
}
|
||||
if (selected.length) {
|
||||
return selected.map(u => ({
|
||||
id: u.id, displayName: u.displayName, userRole: u.userRole || 'other'
|
||||
}));
|
||||
// manual/email let a profile saved from this selection re-add manual users on apply
|
||||
return selected.map(u => Object.assign(
|
||||
{ id: u.id, displayName: u.displayName, userRole: u.userRole || 'other' },
|
||||
u.manual ? { manual: true, email: u.email || '' } : {}));
|
||||
}
|
||||
// Fallback to DOM if S._allUsers not yet populated
|
||||
return Array.from(document.querySelectorAll('.account-check:checked')).map(cb => ({
|
||||
id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other'
|
||||
}));
|
||||
// Once the user list is loaded it is authoritative: no ticked M365 users means
|
||||
// none. (Falling back to the DOM here collected every ticked checkbox —
|
||||
// including Google-only "google:<email>" rows — and sent them to the M365
|
||||
// scan, where Graph 404s on them.)
|
||||
if (S._allUsers.length) return [];
|
||||
// Fallback to DOM only while S._allUsers is not yet populated
|
||||
return Array.from(document.querySelectorAll('.account-check:checked'))
|
||||
.filter(cb => !String(cb.dataset.id || '').startsWith('google:'))
|
||||
.map(cb => ({
|
||||
id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other'
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Result cards must show the account's name and Elev/Ansat role.
|
||||
|
||||
Regression: profiles saved by the profile editor stored bare id strings, and
|
||||
scheduled scans pass those straight to run_scan, which normalised them to
|
||||
displayName=<id>, userRole="other" — so cards showed the GUID as the name and
|
||||
no role badge. run_scan now looks bare accounts up in the directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import scan_engine
|
||||
|
||||
|
||||
class _Conn:
|
||||
"""Directory stub: list_users + role classification + single lookups."""
|
||||
|
||||
def __init__(self, directory=None, extra=None, list_fails=False):
|
||||
self.directory = directory or []
|
||||
self.extra = extra or {} # users only reachable via lookup_user
|
||||
self.list_fails = list_fails
|
||||
self.list_calls = 0
|
||||
self.lookups: list[str] = []
|
||||
|
||||
def list_users(self):
|
||||
self.list_calls += 1
|
||||
if self.list_fails:
|
||||
raise RuntimeError("Authorization_RequestDenied")
|
||||
return list(self.directory)
|
||||
|
||||
def build_role_sku_map(self, users):
|
||||
return {"sku-staff": "STANDARDWOFFPACK_FACULTY", "sku-stud": "STANDARDWOFFPACK_STUDENT"}
|
||||
|
||||
def classify_user_role(self, assigned_licenses, sku_map):
|
||||
names = {sku_map.get(l.get("skuId", ""), "") for l in assigned_licenses}
|
||||
if any("FACULTY" in n for n in names):
|
||||
return "staff"
|
||||
if any("STUDENT" in n for n in names):
|
||||
return "student"
|
||||
return "other"
|
||||
|
||||
def lookup_user(self, uid):
|
||||
self.lookups.append(uid)
|
||||
if uid in self.extra:
|
||||
return self.extra[uid]
|
||||
raise RuntimeError("Request_ResourceNotFound")
|
||||
|
||||
|
||||
DIRECTORY = [
|
||||
{"id": "guid-henrik", "displayName": "Henrik Højmark", "mail": "henrik@school.dk",
|
||||
"assignedLicenses": [{"skuId": "sku-staff"}]},
|
||||
{"id": "guid-elna", "displayName": "Elna Sofie", "mail": "elna@school.dk",
|
||||
"assignedLicenses": [{"skuId": "sku-stud"}]},
|
||||
]
|
||||
|
||||
|
||||
def _bare(uid): # what run_scan makes of a bare id string
|
||||
return {"id": uid, "displayName": uid, "userRole": "other"}
|
||||
|
||||
|
||||
class TestEnrichBareUsers:
|
||||
def test_bare_ids_get_name_and_role_from_directory(self):
|
||||
out = scan_engine._enrich_bare_users(_Conn(DIRECTORY), [_bare("guid-henrik"), _bare("guid-elna")])
|
||||
assert out == [
|
||||
{"id": "guid-henrik", "displayName": "Henrik Højmark", "userRole": "staff"},
|
||||
{"id": "guid-elna", "displayName": "Elna Sofie", "userRole": "student"},
|
||||
]
|
||||
|
||||
def test_named_accounts_untouched_and_directory_not_listed(self):
|
||||
conn = _Conn(DIRECTORY)
|
||||
users = [{"id": "guid-henrik", "displayName": "Henrik Højmark", "userRole": "staff"}]
|
||||
assert scan_engine._enrich_bare_users(conn, users) == users
|
||||
assert conn.list_calls == 0 # no directory call when nothing is bare
|
||||
|
||||
def test_existing_role_is_kept(self):
|
||||
# e.g. a role the UI already knew — only a missing ("other") role is filled
|
||||
out = scan_engine._enrich_bare_users(
|
||||
_Conn(DIRECTORY), [{"id": "guid-elna", "displayName": "guid-elna", "userRole": "staff"}])
|
||||
assert out[0]["displayName"] == "Elna Sofie" and out[0]["userRole"] == "staff"
|
||||
|
||||
def test_account_outside_member_list_is_looked_up(self):
|
||||
extra = {"guid-guest": {"id": "guid-guest", "displayName": "Gæst Lærer", "mail": "g@school.dk",
|
||||
"assignedLicenses": [{"skuId": "sku-staff"}]}}
|
||||
conn = _Conn(DIRECTORY, extra=extra)
|
||||
out = scan_engine._enrich_bare_users(conn, [_bare("guid-guest")])
|
||||
assert out == [{"id": "guid-guest", "displayName": "Gæst Lærer", "userRole": "staff"}]
|
||||
assert conn.lookups == ["guid-guest"]
|
||||
|
||||
def test_directory_list_unavailable_falls_back_to_lookup(self):
|
||||
extra = {"guid-henrik": DIRECTORY[0]}
|
||||
out = scan_engine._enrich_bare_users(_Conn(list_fails=True, extra=extra), [_bare("guid-henrik")])
|
||||
assert out[0]["displayName"] == "Henrik Højmark"
|
||||
|
||||
def test_unknown_account_keeps_what_it_had(self):
|
||||
out = scan_engine._enrich_bare_users(_Conn(DIRECTORY), [_bare("guid-gone")])
|
||||
assert out == [_bare("guid-gone")]
|
||||
|
||||
|
||||
class TestBuildRoleSkuMap:
|
||||
def _conn(self):
|
||||
from m365_connector import M365Connector
|
||||
return M365Connector.__new__(M365Connector) # no auth needed for these calls
|
||||
|
||||
def test_merges_tenant_and_per_user_skus(self, monkeypatch):
|
||||
conn = self._conn()
|
||||
monkeypatch.setattr(conn, "get_subscribed_skus", lambda: {"a": "ADMIN_SKU"})
|
||||
monkeypatch.setattr(conn, "build_sku_map_from_users", lambda users: {"b": "FACULTY_SKU"})
|
||||
assert conn.build_role_sku_map([{"id": "x"}]) == {"a": "ADMIN_SKU", "b": "FACULTY_SKU"}
|
||||
|
||||
def test_never_raises(self, monkeypatch):
|
||||
conn = self._conn()
|
||||
def _boom(*a, **kw): raise RuntimeError("403")
|
||||
monkeypatch.setattr(conn, "get_subscribed_skus", _boom)
|
||||
monkeypatch.setattr(conn, "build_sku_map_from_users", _boom)
|
||||
assert conn.build_role_sku_map([]) == {}
|
||||
|
||||
|
||||
class TestUsersRouteUsesSharedMap:
|
||||
def test_api_users_classifies_with_build_role_sku_map(self, monkeypatch):
|
||||
import gdpr_scanner
|
||||
from routes import state
|
||||
import routes.users as users_mod
|
||||
monkeypatch.setattr(users_mod, "_load_role_overrides", lambda: {})
|
||||
conn = MagicMock()
|
||||
conn.is_app_mode = True
|
||||
conn.list_users.return_value = DIRECTORY
|
||||
conn.build_role_sku_map.return_value = {"sku-staff": "X_FACULTY"}
|
||||
conn.classify_user_role.side_effect = lambda lic, m: "staff" if m else "other"
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
gdpr_scanner.app.config["TESTING"] = True
|
||||
with gdpr_scanner.app.test_client() as c:
|
||||
data = c.get("/api/users").get_json()
|
||||
conn.build_role_sku_map.assert_called_once_with(DIRECTORY)
|
||||
assert {u["userRole"] for u in data["users"]} == {"staff"}
|
||||
|
||||
|
||||
class TestRunScanNamesBareIds:
|
||||
def test_scheduled_style_bare_ids_are_named_in_the_scan(self, monkeypatch):
|
||||
"""End to end: bare id strings (as a scheduled scan passes them) reach the
|
||||
per-user collection with their real name."""
|
||||
from routes import state
|
||||
import sse
|
||||
sent = []
|
||||
monkeypatch.setattr(scan_engine, "broadcast", lambda ev, data: sent.append((ev, data)))
|
||||
monkeypatch.setattr(scan_engine, "DB_OK", False)
|
||||
monkeypatch.setattr(scan_engine, "_load_checkpoint", lambda key, **kw: None)
|
||||
monkeypatch.setattr(scan_engine, "_save_checkpoint", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(scan_engine, "_clear_checkpoint", lambda **kw: None)
|
||||
monkeypatch.setattr(scan_engine, "_load_role_overrides", lambda: {})
|
||||
monkeypatch.setattr(state, "flagged_items", [])
|
||||
stub = _Conn(DIRECTORY)
|
||||
conn = MagicMock()
|
||||
conn.is_app_mode = True
|
||||
conn.list_users.side_effect = stub.list_users
|
||||
conn.build_role_sku_map.side_effect = stub.build_role_sku_map
|
||||
conn.classify_user_role.side_effect = stub.classify_user_role
|
||||
conn.list_all_mail_folders_for.return_value = []
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
saved = sse._current_scan_id
|
||||
try:
|
||||
scan_engine.run_scan({"sources": ["email"], "options": {},
|
||||
"user_ids": ["guid-henrik", "guid-elna"]})
|
||||
finally:
|
||||
sse._current_scan_id = saved
|
||||
phases = " | ".join(d.get("phase", "") for e, d in sent if e == "scan_phase")
|
||||
assert "Henrik Højmark" in phases and "Elna Sofie" in phases
|
||||
assert "guid-henrik" not in phases
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Manual accounts in scan profiles.
|
||||
|
||||
The profile editor stores manually added accounts either with their directory
|
||||
id (looked up via GET /api/users/lookup) or, when M365 wasn't reachable / for
|
||||
profiles saved by older versions, as "manual:<email>". The scan engine must
|
||||
resolve the latter to a Graph user id or they are never scanned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import scan_engine
|
||||
|
||||
|
||||
class _Conn:
|
||||
def __init__(self, directory: dict[str, dict]):
|
||||
self.directory = directory
|
||||
self.lookups: list[str] = []
|
||||
|
||||
def lookup_user(self, upn: str) -> dict:
|
||||
self.lookups.append(upn)
|
||||
if upn not in self.directory:
|
||||
raise RuntimeError("Request_ResourceNotFound")
|
||||
return self.directory[upn]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def broadcasts(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(scan_engine, "broadcast", lambda ev, data: sent.append((ev, data)))
|
||||
return sent
|
||||
|
||||
|
||||
class TestResolveManualUsers:
|
||||
def test_manual_email_resolved_to_directory_id(self, broadcasts):
|
||||
conn = _Conn({"ann@school.dk": {"id": "guid-ann", "displayName": "Ann Andersen",
|
||||
"mail": "ann@school.dk"}})
|
||||
out = scan_engine._resolve_scan_users(conn, [
|
||||
{"id": "manual:ann@school.dk", "displayName": "manual:ann@school.dk", "userRole": "other"},
|
||||
])
|
||||
assert out == [{"id": "guid-ann", "displayName": "Ann Andersen", "userRole": "other"}]
|
||||
assert conn.lookups == ["ann@school.dk"]
|
||||
assert broadcasts == []
|
||||
|
||||
def test_regular_and_resolved_manual_ids_pass_through_without_lookup(self, broadcasts):
|
||||
conn = _Conn({})
|
||||
users = [{"id": "guid-1", "displayName": "One"},
|
||||
{"id": "guid-2", "displayName": "Two", "manual": True, "email": "two@school.dk"}]
|
||||
assert scan_engine._resolve_scan_users(conn, users) == users
|
||||
assert conn.lookups == []
|
||||
|
||||
def test_unknown_manual_account_skipped_and_reported(self, broadcasts):
|
||||
conn = _Conn({})
|
||||
out = scan_engine._resolve_scan_users(conn, [
|
||||
{"id": "guid-1", "displayName": "One"},
|
||||
{"id": "manual:ghost@school.dk", "displayName": "ghost"},
|
||||
])
|
||||
assert out == [{"id": "guid-1", "displayName": "One"}]
|
||||
assert broadcasts and broadcasts[0][0] == "scan_error"
|
||||
assert "ghost@school.dk" in broadcasts[0][1]["file"]
|
||||
|
||||
def test_manual_account_already_selected_is_deduplicated(self, broadcasts):
|
||||
conn = _Conn({"ann@school.dk": {"id": "guid-ann", "displayName": "Ann"}})
|
||||
out = scan_engine._resolve_scan_users(conn, [
|
||||
{"id": "guid-ann", "displayName": "Ann"},
|
||||
{"id": "manual:ann@school.dk", "displayName": "ann"},
|
||||
])
|
||||
assert [u["id"] for u in out] == ["guid-ann"]
|
||||
|
||||
def test_all_manual_unresolvable_gives_empty_list_not_all_users(self, broadcasts):
|
||||
# run_scan resolves inside the non-empty branch, so an empty result must
|
||||
# stay empty — it must never widen to "scan every tenant user".
|
||||
conn = _Conn({})
|
||||
assert scan_engine._resolve_scan_users(
|
||||
conn, [{"id": "manual:x@school.dk", "displayName": "x"}]) == []
|
||||
|
||||
|
||||
class TestGoogleOnlyAccountsDropped:
|
||||
"""Regression: google:<email> ids reached the M365 scan and Graph 404'd
|
||||
("/users/google:anne@school.dk/mailFolders")."""
|
||||
|
||||
def test_google_only_accounts_are_dropped_and_reported(self, broadcasts):
|
||||
conn = _Conn({})
|
||||
out = scan_engine._resolve_scan_users(conn, [
|
||||
{"id": "guid-1", "displayName": "One"},
|
||||
{"id": "google:anne@school.dk", "displayName": "Anne"},
|
||||
{"id": "google:appleadmin@adm.school.dk", "displayName": "Apple Admin"},
|
||||
])
|
||||
assert out == [{"id": "guid-1", "displayName": "One"}]
|
||||
assert conn.lookups == []
|
||||
phases = [d["phase"] for e, d in broadcasts if e == "scan_phase"]
|
||||
assert phases and "2" in phases[0]
|
||||
|
||||
def test_only_google_accounts_gives_empty_list_not_all_users(self, broadcasts):
|
||||
assert scan_engine._resolve_scan_users(_Conn({}), [
|
||||
{"id": "google:anne@school.dk", "displayName": "Anne"}]) == []
|
||||
|
||||
def test_run_scan_never_calls_graph_for_google_ids(self, broadcasts, monkeypatch):
|
||||
from routes import state
|
||||
monkeypatch.setattr(scan_engine, "DB_OK", False)
|
||||
monkeypatch.setattr(scan_engine, "_load_checkpoint", lambda key, **kw: None)
|
||||
monkeypatch.setattr(scan_engine, "_save_checkpoint", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(scan_engine, "_clear_checkpoint", lambda **kw: None)
|
||||
monkeypatch.setattr(state, "flagged_items", [])
|
||||
conn = MagicMock()
|
||||
conn.is_app_mode = True
|
||||
conn.list_all_mail_folders_for.return_value = []
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
import sse
|
||||
saved = sse._current_scan_id
|
||||
try:
|
||||
scan_engine.run_scan({"sources": ["email"], "options": {}, "user_ids": [
|
||||
{"id": "google:anne@school.dk", "displayName": "Anne"},
|
||||
{"id": "guid-1", "displayName": "One"},
|
||||
]})
|
||||
finally:
|
||||
sse._current_scan_id = saved
|
||||
called_ids = {c.args[0] for c in conn.list_all_mail_folders_for.call_args_list}
|
||||
assert "guid-1" in called_ids
|
||||
assert not any(str(i).startswith("google:") for i in called_ids)
|
||||
conn.list_users.assert_not_called() # never widened to the whole tenant
|
||||
|
||||
|
||||
class TestLookupRoute:
|
||||
@pytest.fixture()
|
||||
def client(self):
|
||||
import gdpr_scanner
|
||||
gdpr_scanner.app.config["TESTING"] = True
|
||||
with gdpr_scanner.app.test_client() as c:
|
||||
yield c
|
||||
|
||||
def test_lookup_uses_connector(self, client, monkeypatch):
|
||||
from routes import state
|
||||
conn = MagicMock()
|
||||
conn.lookup_user.return_value = {"id": "guid-ann", "displayName": "Ann",
|
||||
"mail": "ann@school.dk"}
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
r = client.get("/api/users/lookup?upn=ann@school.dk")
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["id"] == "guid-ann"
|
||||
conn.lookup_user.assert_called_once_with("ann@school.dk")
|
||||
|
||||
def test_lookup_not_found_is_404(self, client, monkeypatch):
|
||||
from routes import state
|
||||
conn = MagicMock()
|
||||
conn.lookup_user.side_effect = RuntimeError("Request_ResourceNotFound")
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
assert client.get("/api/users/lookup?upn=ghost@school.dk").status_code == 404
|
||||
@@ -661,3 +661,77 @@ class TestProfileRoutes:
|
||||
def test_delete_missing_key_returns_400(self, client):
|
||||
r = client.post("/api/profiles/delete", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scan start must not write profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScanStartLeavesProfilesAlone:
|
||||
"""
|
||||
Regression: POST /api/scan/start used to call _save_settings() with the
|
||||
sidebar state, overwriting the active profile's user_ids (dropping
|
||||
Google-only users) — or profiles[0] when no profile was active.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(self, tmp_path, monkeypatch):
|
||||
import app_config, scan_engine
|
||||
monkeypatch.setattr(app_config, "_SETTINGS_PATH", tmp_path / "settings.json")
|
||||
monkeypatch.setattr(scan_engine, "run_scan", lambda opts: None)
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_lock():
|
||||
from routes import state
|
||||
deadline = time.time() + 2.0
|
||||
while not state._scan_lock.acquire(blocking=False):
|
||||
assert time.time() < deadline, "scan lock was never released"
|
||||
time.sleep(0.05)
|
||||
state._scan_lock.release()
|
||||
|
||||
def _save(self, client, name, user_ids):
|
||||
r = client.post("/api/profiles/save", json={
|
||||
"name": name, "sources": ["email", "gmail"],
|
||||
"google_sources": ["gmail"], "user_ids": user_ids, "options": {},
|
||||
})
|
||||
return r.get_json()["profile"]
|
||||
|
||||
def _get(self, client, pid):
|
||||
return client.get(f"/api/profiles/get?id={pid}").get_json()["profile"]
|
||||
|
||||
def test_active_profile_users_unchanged(self, client, mock_connector):
|
||||
users = ["m365-a", "google:b@school.dk", "manual:c@school.dk"]
|
||||
p = self._save(client, "Staff", users)
|
||||
r = client.post("/api/scan/start", json={
|
||||
"sources": ["email"],
|
||||
"user_ids": [{"id": "m365-z", "displayName": "Z"}],
|
||||
"options": {"older_than_days": 0},
|
||||
"profile_id": p["id"],
|
||||
})
|
||||
assert r.status_code == 200
|
||||
self._wait_for_lock()
|
||||
after = self._get(client, p["id"])
|
||||
assert after["user_ids"] == users
|
||||
assert after["sources"] == ["email", "gmail"]
|
||||
|
||||
def test_no_active_profile_leaves_first_profile_unchanged(self, client, mock_connector):
|
||||
first = self._save(client, "First", ["m365-a", "m365-b"])
|
||||
self._save(client, "Second", ["m365-c"])
|
||||
r = client.post("/api/scan/start", json={
|
||||
"sources": ["email"],
|
||||
"user_ids": [{"id": "m365-z", "displayName": "Z"}],
|
||||
"options": {},
|
||||
"profile_id": None,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
self._wait_for_lock()
|
||||
assert self._get(client, first["id"])["user_ids"] == ["m365-a", "m365-b"]
|
||||
assert len(client.get("/api/profiles").get_json()["profiles"]) == 2
|
||||
|
||||
def test_scan_start_creates_no_profile(self, client, mock_connector):
|
||||
r = client.post("/api/scan/start", json={
|
||||
"sources": ["email"], "user_ids": [{"id": "m365-z"}], "options": {},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
self._wait_for_lock()
|
||||
assert client.get("/api/profiles").get_json()["profiles"] == []
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Stop must stop — regressions for "pressing Stop seems to restart the scan".
|
||||
|
||||
- SSE replay: a stopped/finished scan's buffered events are never replayed to
|
||||
a reconnecting browser (that re-showed the Stop button and progress bar).
|
||||
- run_scan: Stop during collection ends the scan (checkpoint + scan_cancelled)
|
||||
instead of announcing scan_start / "Scanning…".
|
||||
- Scheduler: Stop during one stage prevents later stages — in particular the
|
||||
Google stage no longer clears the Google abort flag and scans everyone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import sse
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_scan_state():
|
||||
"""Restore SSE globals and abort flags so tests don't leak into each other."""
|
||||
from routes import state
|
||||
saved_buf = list(sse._sse_buffer)
|
||||
saved_id = sse._current_scan_id
|
||||
saved_finished = list(sse._finished_scan_ids)
|
||||
sse._sse_buffer.clear()
|
||||
sse._current_scan_id = ""
|
||||
sse._finished_scan_ids.clear()
|
||||
yield
|
||||
state._scan_abort.clear()
|
||||
state._google_scan_abort.clear()
|
||||
sse._sse_buffer.clear()
|
||||
sse._sse_buffer.extend(saved_buf)
|
||||
sse._current_scan_id = saved_id
|
||||
sse._finished_scan_ids.clear()
|
||||
sse._finished_scan_ids.extend(saved_finished)
|
||||
|
||||
|
||||
def _events(msgs: list[str]) -> list[str]:
|
||||
return [m.split("\n", 1)[0].removeprefix("event: ") for m in msgs]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE replay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReplay:
|
||||
def test_running_scan_is_replayed(self):
|
||||
sse._current_scan_id = "scan_1"
|
||||
sse.broadcast("scan_phase", {"phase": "Collecting…"})
|
||||
sse.broadcast("scan_progress", {"pct": 10})
|
||||
assert _events(sse.replay_messages()) == ["scan_phase", "scan_progress"]
|
||||
|
||||
def test_stopped_scan_is_not_replayed(self):
|
||||
sse._current_scan_id = "scan_1"
|
||||
sse.broadcast("scan_phase", {"phase": "Collecting…"})
|
||||
sse.broadcast("scan_start", {"total": 5})
|
||||
sse.broadcast("scan_progress", {"pct": 40})
|
||||
sse.broadcast("scan_cancelled", {"completed": 2})
|
||||
assert sse._current_scan_id == ""
|
||||
assert sse.replay_messages() == []
|
||||
|
||||
def test_completed_scan_is_not_replayed(self):
|
||||
sse._current_scan_id = "scan_1"
|
||||
sse.broadcast("scan_progress", {"pct": 100})
|
||||
sse.broadcast("scan_done", {"total_scanned": 1, "flagged_count": 0})
|
||||
assert sse.replay_messages() == []
|
||||
|
||||
def test_file_scan_done_ends_the_file_scan(self):
|
||||
# Previously only scan_done cleared the id, so every finished file scan
|
||||
# stayed "current" and was replayed on each page load.
|
||||
sse._current_scan_id = "filescan_1"
|
||||
sse.broadcast("scan_phase", {"phase": "Files — Share"})
|
||||
sse.broadcast("file_scan_done", {"total_scanned": 3, "flagged_count": 0})
|
||||
assert sse._current_scan_id == ""
|
||||
assert sse.replay_messages() == []
|
||||
|
||||
def test_untagged_events_still_replayed_after_scan_ends(self):
|
||||
sse._current_scan_id = "scan_1"
|
||||
sse.broadcast("scan_progress", {"pct": 40})
|
||||
sse.broadcast("scan_cancelled", {"completed": 2})
|
||||
sse.broadcast("google_scan_done", {"total_scanned": 7}) # no scan running → untagged
|
||||
assert _events(sse.replay_messages()) == ["google_scan_done"]
|
||||
|
||||
def test_new_scan_replays_only_its_own_events(self):
|
||||
sse._current_scan_id = "scan_1"
|
||||
sse.broadcast("scan_progress", {"pct": 40})
|
||||
sse.broadcast("scan_cancelled", {"completed": 2})
|
||||
sse._current_scan_id = "scan_2"
|
||||
sse.broadcast("scan_phase", {"phase": "Collecting…"})
|
||||
msgs = sse.replay_messages()
|
||||
assert _events(msgs) == ["scan_phase"]
|
||||
assert '"scan_id": "scan_2"' in msgs[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_scan: Stop during collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunScanStopDuringCollection:
|
||||
@pytest.fixture()
|
||||
def engine(self, monkeypatch):
|
||||
import scan_engine
|
||||
from routes import state
|
||||
sent: list[tuple[str, dict]] = []
|
||||
checkpoints: list = []
|
||||
monkeypatch.setattr(scan_engine, "broadcast", lambda ev, data: sent.append((ev, data)))
|
||||
monkeypatch.setattr(scan_engine, "DB_OK", False)
|
||||
monkeypatch.setattr(scan_engine, "_load_checkpoint", lambda key, **kw: None)
|
||||
monkeypatch.setattr(scan_engine, "_save_checkpoint", lambda *a, **kw: checkpoints.append(a))
|
||||
monkeypatch.setattr(scan_engine, "_clear_checkpoint", lambda **kw: None)
|
||||
conn = MagicMock()
|
||||
conn.is_app_mode = True
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
monkeypatch.setattr(state, "flagged_items", [])
|
||||
opts = {"sources": [], "user_ids": [{"id": "u1", "displayName": "User"}], "options": {}}
|
||||
return scan_engine, sent, checkpoints, opts
|
||||
|
||||
def test_stop_before_processing_does_not_announce_scan_start(self, engine):
|
||||
scan_engine, sent, checkpoints, opts = engine
|
||||
from routes import state
|
||||
state._scan_abort.set()
|
||||
scan_engine.run_scan(opts)
|
||||
names = [e for e, _ in sent]
|
||||
assert "scan_cancelled" in names
|
||||
assert "scan_start" not in names
|
||||
assert not any(e == "scan_phase" and "Scanning" in d.get("phase", "") for e, d in sent)
|
||||
assert "scan_done" not in names
|
||||
assert len(checkpoints) == 1 # resumable
|
||||
|
||||
def test_without_stop_scan_runs_to_done(self, engine):
|
||||
scan_engine, sent, _, opts = engine
|
||||
scan_engine.run_scan(opts)
|
||||
names = [e for e, _ in sent]
|
||||
assert "scan_start" in names and "scan_done" in names
|
||||
assert "scan_cancelled" not in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler: Stop ends the whole job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchedulerStop:
|
||||
JOB = {"id": "j1", "name": "Nightly", "profile_id": "", "enabled": True,
|
||||
"auto_email": True, "auto_retention": True, "retention_years": 5}
|
||||
OPTIONS = {"sources": ["email", "gmail"], "user_ids": [], "options": {},
|
||||
"file_sources": [{"id": "f1", "path": "/srv/share", "label": "Share"}],
|
||||
"google_sources": ["gmail"]}
|
||||
|
||||
@pytest.fixture()
|
||||
def job(self, monkeypatch):
|
||||
import gdpr_scanner
|
||||
import scan_scheduler
|
||||
import routes.google_scan as google_scan
|
||||
from routes import state
|
||||
|
||||
sent: list[tuple[str, dict]] = []
|
||||
monkeypatch.setattr(scan_scheduler, "load_jobs", lambda: [dict(self.JOB)])
|
||||
monkeypatch.setattr(gdpr_scanner, "_get_db", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(gdpr_scanner, "broadcast", lambda ev, data: sent.append((ev, data)))
|
||||
conn = MagicMock()
|
||||
conn.is_authenticated.return_value = True
|
||||
monkeypatch.setattr(state, "connector", conn)
|
||||
monkeypatch.setattr(state, "google_connector", MagicMock())
|
||||
monkeypatch.setattr(state, "flagged_items", [{"id": "found"}])
|
||||
|
||||
mocks = {
|
||||
"run_scan": MagicMock(),
|
||||
"run_file_scan": MagicMock(),
|
||||
"google": MagicMock(),
|
||||
}
|
||||
monkeypatch.setattr(gdpr_scanner, "run_scan", mocks["run_scan"])
|
||||
monkeypatch.setattr(gdpr_scanner, "run_file_scan", mocks["run_file_scan"])
|
||||
monkeypatch.setattr(google_scan, "_run_google_scan", mocks["google"])
|
||||
|
||||
sched = scan_scheduler.ScanScheduler()
|
||||
monkeypatch.setattr(sched, "_build_options", lambda cfg: {
|
||||
k: (list(v) if isinstance(v, list) else dict(v) if isinstance(v, dict) else v)
|
||||
for k, v in self.OPTIONS.items()})
|
||||
mocks["email"] = MagicMock()
|
||||
mocks["retention"] = MagicMock()
|
||||
monkeypatch.setattr(sched, "_send_email_report", mocks["email"])
|
||||
monkeypatch.setattr(sched, "_run_retention", mocks["retention"])
|
||||
return sched, mocks, sent
|
||||
|
||||
def test_stop_during_m365_stage_skips_file_and_google(self, job):
|
||||
from routes import state
|
||||
sched, mocks, sent = job
|
||||
|
||||
def _user_presses_stop(opts): # what POST /api/scan/stop does mid-scan
|
||||
state._scan_abort.set()
|
||||
state._google_scan_abort.set()
|
||||
mocks["run_scan"].side_effect = _user_presses_stop
|
||||
|
||||
sched._execute_scan("j1")
|
||||
|
||||
mocks["run_scan"].assert_called_once()
|
||||
mocks["run_file_scan"].assert_not_called()
|
||||
mocks["google"].assert_not_called()
|
||||
assert state._google_scan_abort.is_set(), "Google abort must not be cleared after Stop"
|
||||
run = sched._last_runs["j1"]
|
||||
assert run["status"] == "cancelled"
|
||||
mocks["email"].assert_not_called()
|
||||
mocks["retention"].assert_not_called()
|
||||
done = [d for e, d in sent if e == "scheduler_done"]
|
||||
assert done and done[0]["cancelled"] is True
|
||||
|
||||
def test_uninterrupted_job_runs_every_stage(self, job):
|
||||
sched, mocks, sent = job
|
||||
sched._execute_scan("j1")
|
||||
mocks["run_scan"].assert_called_once()
|
||||
mocks["run_file_scan"].assert_called_once()
|
||||
mocks["google"].assert_called_once()
|
||||
assert sched._last_runs["j1"]["status"] == "completed"
|
||||
mocks["email"].assert_called_once()
|
||||
mocks["retention"].assert_called_once()
|
||||
done = [d for e, d in sent if e == "scheduler_done"]
|
||||
assert done and done[0]["cancelled"] is False
|
||||
Reference in new issue
Block a user