feat: scan history browser, user-scoped viewer tokens, export fixes, email fixes (v1.6.20)
- Scan history browser (history.js, GET /api/db/sessions, get_sessions(), get_session_items(ref_scan_id)) — review any past session without rescanning - User-scoped viewer tokens (#34) — scope by individual employee across M365 and GWS; autocomplete from Accounts list; dual-email support - Fix: GWS scan never marked finished (end_scan → finish_scan) and emitted wrong SSE event (scan_done → google_scan_done), excluding GWS items from all exports - Fix: file scan begin_scan called with wrong keyword args (TypeError swallowed), so local/SMB items were never written to DB - Fix: Graph sendMail reported failure on success — _post() now returns {} on empty 202 response instead of raising JSONDecodeError - Fix: Graph error hidden behind generic "No SMTP host" message when both Graph and SMTP were unavailable - Fix: Gmail vs Google Workspace SMTP error messages distinguished by username domain; Workspace errors point to admin console, not personal security settings - Docs: update README, MANUAL-EN, MANUAL-DA, CLAUDE.md, TODO.md, CHANGELOG.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e64d7eb958
commit
c9aab19a97
56
CHANGELOG.md
56
CHANGELOG.md
@ -7,6 +7,62 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
||||
|
||||
---
|
||||
|
||||
## [1.6.20] — 2026-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Graph `sendMail` reported as failure despite email being delivered** — `_post()` in `m365_connector.py` called `r.json()` unconditionally after `raise_for_status()`. The Graph `sendMail` endpoint returns HTTP 202 with an empty body on success, causing `json.JSONDecodeError: Expecting value: line 1 column 1 (char 0)`. This was caught by the `smtp_test` exception handler and surfaced as an error even though the email had been sent. Fixed by returning `r.json() if r.content else {}` so any Graph endpoint that responds with no body (sendMail, delete operations, etc.) is handled correctly.
|
||||
|
||||
- **Graph error hidden when SMTP host not configured** — when Graph failed and no SMTP host was saved, `smtp_test` returned the generic "No SMTP host configured" message, swallowing the actual Graph error. The `if not host` branch now surfaces the Graph exception text alongside the Mail.Send permission guidance so the real cause is visible.
|
||||
|
||||
- **Gmail vs Google Workspace SMTP error messages** — the auth failure handler now detects whether the username is a personal Gmail address (`@gmail.com`) or a Google Workspace custom-domain account, and shows a different message for each. Personal Gmail: existing App Password troubleshooting steps. Google Workspace: explains that SMTP access is controlled by the Workspace admin console (2-Step Verification policy, SMTP relay service), not the user's personal security settings.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.19] — 2026-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gmail SMTP error message misleading when App Password already in use** — the auth failure handler in both `smtp_test` and `send_report` unconditionally told the user to "create an App Password", even when they were already using one. Gmail returns the same `535` / `Username and Password not accepted` error for a wrong app password, a revoked app password, spaces left in the 16-character code, or a wrong username — none of which are helped by the old message. The Gmail branch now lists the three most common causes (spaces in the code, revoked password, wrong username) and still links to the App Password page to generate a new one. The Microsoft personal account branch is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.18] — 2026-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Art.30 and Excel exports missing GWS and local/SMB sources** — two silent failures caused Google Workspace and file-scan results to be absent from all exports after a page reload.
|
||||
- `routes/google_scan.py`: called `_db.end_scan()` (method does not exist on `GDPRDb` — the correct name is `finish_scan`). The resulting `AttributeError` was swallowed by the bare `except Exception: pass` guard, so `finished_at` was never written on GWS scan records. Since `get_session_items()` requires `finished_at IS NOT NULL`, every GWS scan was permanently invisible to both export functions.
|
||||
- `routes/google_scan.py`: emitted `"scan_done"` at completion instead of `"google_scan_done"`, causing the M365 done handler to fire for Google scans and breaking the SSE teardown logic.
|
||||
- `scan_engine.py` (`run_file_scan`): called `_db.begin_scan(sources=…, user_count=0, options=source)` with keyword arguments, but `begin_scan(self, options: dict)` only accepts a single positional dict. The `TypeError` was caught silently, leaving `_db_scan_id = None`; all subsequent `save_item` calls were skipped, so local and SMB items were never written to the database.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.17] — 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Scan history browser** — results from any past scan session can now be reviewed without running a new scan. On page load, when no scan is running, the last completed session is automatically loaded into the results grid. A **History** banner appears above the filter bar showing the session date, scanned sources, and item count. A **Sessions** button in the banner opens a dropdown listing all past sessions newest-first, each showing date, time, source labels, item count, and Delta / Latest badges. Clicking a session loads its items. A **Latest scan** button (shown only when browsing a past session) jumps back to the most recent session. Starting a new scan exits history mode and takes over the grid with live SSE results. Session cache is invalidated on each scan completion so the picker always reflects the true state of the database.
|
||||
|
||||
- `gdpr_db.py` — new `get_sessions(limit, window_seconds)` groups all completed scans by the 300-second concurrent-scan window and returns session summaries newest-first. `get_session_items()` gains an optional `ref_scan_id` parameter to anchor the session window to any past scan.
|
||||
- `routes/database.py` — new `GET /api/db/sessions`; `GET /api/db/flagged` now accepts `?ref=<scan_id>` to serve items for a specific historical session.
|
||||
- `static/js/history.js` (new) — `loadHistorySession(refScanId)`, `openHistoryPicker()`, `closeHistoryPicker()`, `exitHistoryMode()`, `invalidateHistoryCache()` all exposed on `window`.
|
||||
- `state.js` — `_historyRefScanId: null` tracks which session is currently displayed (`null` = live/SSE).
|
||||
- `results.js` — initial status check calls `loadHistorySession(null)` instead of `loadLastScanSummary()`.
|
||||
- `scan.js` — `startScan()` calls `exitHistoryMode()`; all three `*_done` handlers call `invalidateHistoryCache()`.
|
||||
|
||||
- **User-scoped viewer tokens (#34)** — viewer token links can now be restricted to a specific person so the recipient sees only their own flagged files, across both M365 and Google Workspace. The Share modal's scope selector gains a **User** option that opens a searchable name autocomplete backed by the already-loaded `S._allUsers` list. Typing filters by display name or email; each row shows the person's full name, role badge, and all associated email addresses (M365 UPN and GWS email shown together for dual-platform users). Selecting a name fills the input with the display name and stores both email addresses internally. Scope is stored as `{"user": ["alice@m365.dk", "alice@gws.dk"], "display_name": "Alice Smith"}`. Server-side enforcement in `GET /api/db/flagged` filters `WHERE account_id IN (list)` so items from either platform are included. The viewer header shows the person's full name in a locked identity badge (`#viewerIdentityBadge`); `#filterRole` is hidden. Token rows in the Active links list show the display name badge. Free-text email entry still works as a fallback when no accounts are loaded. File-scan items (`account_id = ""`) never appear in user-scoped views — consistent with the existing role-scope behaviour.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.16] — 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **User-scoped viewer tokens (#34)** — viewer token links can now be restricted to a specific person so the recipient sees only their own flagged files, across both M365 and Google Workspace. The Share modal's scope selector gains a **User** option that opens a searchable name autocomplete backed by the already-loaded `S._allUsers` list. Typing filters by display name or email; each row shows the person's full name, role badge, and all associated email addresses (M365 UPN and GWS email shown together for dual-platform users). Selecting a name fills the input with the display name and stores both email addresses internally. Scope is stored as `{"user": ["alice@m365.dk", "alice@gws.dk"], "display_name": "Alice Smith"}`. Server-side enforcement in `GET /api/db/flagged` filters `WHERE account_id IN (list)` so items from either platform are included. The viewer header shows the person's full name in a locked identity badge (`#viewerIdentityBadge`); `#filterRole` is hidden. Token rows in the Active links list show the display name badge. Free-text email entry still works as a fallback when no accounts are loaded. File-scan items (`account_id = ""`) never appear in user-scoped views — consistent with the existing role-scope behaviour.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.15] — 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@ -51,7 +51,7 @@ Read-only access for DPOs and reviewers. Key invariants:
|
||||
- **`app.secret_key`** — derived from `machine_id` bytes so Flask sessions survive restarts. Set once at startup in `gdpr_scanner.py`; do not override it.
|
||||
- **`GET /api/db/flagged`** — returns `get_session_items()` (last completed scan session, joined with dispositions), filtered by `session["viewer_scope"].role` when set. Used exclusively by `_loadViewerResults()` in `results.js`. Do not confuse with `get_flagged_items()` (single scan_id, no disposition join).
|
||||
- **Rate-limit state** (`_pin_attempts` dict in `routes/viewer.py`) — in-memory only, resets on server restart. Intentional — a restart clears lockouts without a persistent store.
|
||||
- **User-scoped tokens (planned #34)** — scope `{"user": "alice@school.dk"}` will filter `GET /api/db/flagged` by `account_id` (indexed column, already populated: M365 → UPN, Google → email). File-scan items have `account_id = ""` and won't appear. Server enforcement mirrors the role filter: `user = session.get("viewer_scope", {}).get("user"); if user: add WHERE account_id = user`. Do not combine `user` + `role` in a single scope — they are orthogonal use cases.
|
||||
- **User-scoped tokens (#34)** — scope `{"user": ["alice@m365.dk", "alice@gws.dk"], "display_name": "Alice Smith"}` filters `GET /api/db/flagged` by `account_id IN (list)`, covering both M365 and GWS items for the same person. `scope.user` is always stored as a list; a legacy single-string value is coerced to `[string]` on read. `scope.display_name` is used for UI only (badge, viewer header) — not for filtering. File-scan items (`account_id = ""`) never appear in user-scoped views. `POST /api/viewer/tokens` rejects combined `role`+`user` scope with 400. Share modal: scope-type `<select>` (`#shareScopeType`) reveals either the role dropdown (`#shareScopeRoleWrap`) or a name-search autocomplete (`#shareScopeUserWrap`). Autocomplete reads `S._allUsers`; selecting a row stores `{ emails, display_name }` in module-level `_selectedScopeUser`; editing the input manually clears it (free-text email fallback). In viewer mode, `auth.js` shows `#viewerIdentityBadge` with `VIEWER_SCOPE.display_name`.
|
||||
- **Token onclick attributes** — Copy/Revoke buttons in `_renderTokenList()` pass the token as a single-quoted JS string literal (`'\'' + tok.token + '\''`), never via `JSON.stringify`. `JSON.stringify` produces double-quoted strings that break the surrounding `onclick="…"` HTML attribute.
|
||||
- **Settings Security pane** — Admin PIN and Viewer PIN groups live in `stPaneSecurity`, not `stPaneGeneral`. `switchSettingsTab('security')` in `sources.js` triggers both `stLoadPinStatus()` and `stLoadViewerPinStatus()`. The Share modal Configure button opens `openSettings('security')`.
|
||||
- **`stClearViewerPin` guard** — validates that the current-PIN field is non-empty client-side before sending the DELETE request; shows an inline error and focuses the field if empty.
|
||||
@ -106,12 +106,33 @@ Large M365 tenants can generate enormous memory pressure. Key rules to preserve:
|
||||
- **ART.30 breakdown table** — iterates `scanned_sources` (not `by_source`) so Gmail, Google Drive, etc. appear with `0 | 0 | 0 | —` when the scan found nothing.
|
||||
- **Role-filtered exports** — `_build_excel_bytes(role='')` and `_build_article30_docx(role='')` accept `role='student'` or `role='staff'`. A local `_items` list is built at the top of each function and used everywhere instead of `state.flagged_items` directly — GPS sheet, External transfers sheet, and Art.30 staff/student tables all see only the filtered subset. Route handlers read `request.args.get('role', '')` and forward it. Filenames get `_elever` / `_ansatte` suffix. The `#filterRole` dropdown in the filter bar drives both the client-side grid filter and the export URL param — do not separate them.
|
||||
|
||||
## Scan history browser — static/js/history.js + gdpr_db.py + routes/database.py
|
||||
|
||||
Allows reviewing results from any past scan session without running a new scan. Key invariants:
|
||||
|
||||
- **`S._historyRefScanId`** — `null` = live/SSE mode; positive int = viewing a past session (the highest `scan_id` in that session's 300 s window). Set by `loadHistorySession()`; cleared to `null` by `exitHistoryMode()`.
|
||||
- **`GET /api/db/sessions`** (`routes/database.py`) — calls `_get_db().get_sessions()`. Returns newest-first list; each entry has `ref_scan_id`, `started_at`, `finished_at`, `sources` (list of source-key strings), `flagged_count`, `total_scanned`, `delta` (bool). No auth restriction — viewer tokens share this endpoint.
|
||||
- **`get_sessions(limit=50, window_seconds=300)`** (`gdpr_db.py`) — groups `scans` rows by 300 s window (same window logic as `get_session_items`). Groups are built ascending, returned descending. `ref_scan_id` is the highest `scan_id` in each group. Do not change the window size independently of `get_session_items`.
|
||||
- **`get_session_items(ref_scan_id=N)`** (`gdpr_db.py`) — when `ref_scan_id` is given, anchors the 300 s window to that scan's `started_at`. Falls back to latest scan when `ref_scan_id=None`.
|
||||
- **`GET /api/db/flagged?ref=N`** — passes `ref_scan_id` to `get_session_items`; viewer scope enforcement (role/user filters) still applies. Used by both history mode and the normal post-scan viewer path.
|
||||
- **History banner** (`#historyBanner`) — shown when `S._historyRefScanId` is set. Contains `#historyBannerText` (session date · sources · N items), `#historyPickerBtn` (opens `#historyDropdown`), and `#historyLatestBtn` (visible only when the viewed session is not the latest). Do not hide/show these elements from outside `history.js`.
|
||||
- **Session picker** (`#historyDropdown`) — rendered inside `[data-history-wrap]` container so the outside-click handler (`document` listener, closes on clicks outside `[data-history-wrap]`) works correctly. Do not move the picker outside this wrapper.
|
||||
- **Cache invalidation** — `_sessions` and `_latestRefScanId` are module-level in `history.js`. `invalidateHistoryCache()` clears both. All three `*_done` SSE handlers in `scan.js` call `window.invalidateHistoryCache?.()` so the picker reflects the newest scan after completion.
|
||||
- **Auto-load on page load** — `results.js` calls `window.loadHistorySession?.(null)` once when the SSE watchdog confirms `!status.running`. `null` resolves to the latest completed session via `_fetchSessions()[0].ref_scan_id`. The `_initialStatusChecked` guard ensures this fires at most once per page load.
|
||||
- **Mode transitions** — `startScan()` calls `window.exitHistoryMode?.()` before clearing the grid, so any history banner is dismissed and `S._historyRefScanId` is reset before SSE events start arriving.
|
||||
|
||||
## SSE teardown — static/js/scan.js
|
||||
|
||||
- **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. If M365 finishes first and the SSE is closed, the remaining done events are never received and the UI hangs at 100% indefinitely.
|
||||
- **Rule:** close `S.es` (and reset `S._userStartedScan`) only inside the branch where *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 the SSE connection is never closed and future scheduler events continue to arrive.
|
||||
|
||||
## Email sending — routes/email.py + m365_connector.py
|
||||
|
||||
- **`_post()` returns `{}` on empty body** — `m365_connector._post()` returns `r.json() if r.content else {}`. The Graph `sendMail` endpoint returns HTTP 202 with **no body** on success; calling `r.json()` on an empty response raises `JSONDecodeError`. Do not change this back to an unconditional `r.json()` — it would falsely report every successful email send as an error.
|
||||
- **Graph preferred over SMTP** — `smtp_test` and `send_report` both try `_send_email_graph()` first when `state.connector` is authenticated. Only falls back to SMTP if Graph raises. If Graph fails and no SMTP host is saved, the Graph exception is surfaced directly (not swallowed by the "No SMTP host" message).
|
||||
- **Gmail vs Google Workspace detection** — auth error handlers check whether the SMTP username ends in `@gmail.com` / `@googlemail.com`. If not, the account is treated as Google Workspace (custom domain) and the error message points to the Workspace admin console rather than the user's personal security settings.
|
||||
|
||||
## Global gotchas
|
||||
|
||||
- **Pattern matching in Python** — when using `str.replace()` to patch JS/HTML, whitespace and quote style must match exactly. Use `in` check first and print if not found.
|
||||
|
||||
23
README.md
23
README.md
@ -42,7 +42,7 @@ an IDE with intelligent completion. The result is the author's work.
|
||||
- **Retention policy enforcement** — flag items older than a configurable retention period with a Overdue badge; supports both rolling and fiscal-year-aligned cutoffs (e.g. Bogføringsloven Dec 31); headless auto-delete via `--retention-years`
|
||||
- **Data subject lookup** — find all flagged items containing a specific CPR number across all scans; CPR is SHA-256 hashed before querying — never stored in plaintext
|
||||
- **Disposition tagging** — compliance officers can tag each flagged item with a legal basis (retain / delete-scheduled / deleted) directly from the preview panel
|
||||
- **Read-only viewer mode** — share scan results with a DPO or manager via a secure token URL (`/view?token=…`) or a numeric PIN; viewers see the full results grid and disposition panel but cannot scan, delete, or change settings. Tokens can be **role-scoped** (Ansatte / Elever) so a recipient can only see the items relevant to their remit
|
||||
- **Read-only viewer mode** — share scan results with a DPO or manager via a secure token URL (`/view?token=…`) or a numeric PIN; viewers see the full results grid and disposition panel but cannot scan, delete, or change settings. Tokens can be **role-scoped** (Ansatte / Elever) so a recipient only sees items for their group, or **user-scoped** so an individual employee only sees their own flagged files (supports dual M365 + Google Workspace identity)
|
||||
- **Article 30 report** — one-click export of a structured Word document (`.docx`) satisfying the GDPR Article 30 register of processing activities obligation
|
||||
- **SQLite results database** — scan results, CPR index, PII breakdown, disposition decisions, and scan history are persisted to `~/.gdprscanner/scanner.db` alongside the JSON cache, enabling cross-scan queries and trend tracking
|
||||
- **Built-in user manual** — click the **?** button in the top bar to open the manual in a dedicated window. Available in Danish and English. Printable via the browser's print function. Served from `MANUAL-DA.md` / `MANUAL-EN.md` at `/manual?lang=da|en` — always in sync with the installed version, no internet required. In the packaged desktop app the manual opens as a native pywebview window; in the browser it opens as a popup.
|
||||
@ -157,6 +157,17 @@ Each flagged item appears as a card showing:
|
||||
|
||||
The Role filter also scopes exports — selecting **Elever** before clicking **Excel** or **Art.30** produces a report containing only student items. The exported filename gets an `_elever` or `_ansatte` suffix so recipients can distinguish the files.
|
||||
|
||||
#### Scan history browser
|
||||
|
||||
Review results from any past scan session without running a new scan. A **Sessions** button appears in the banner above the results grid once a scan has completed.
|
||||
|
||||
- Click **Sessions** to open the session picker — lists all past scans with date, sources, and item count. Each entry shows a **Δ** badge for delta scans and a **Latest** badge for the most recent session.
|
||||
- Click any session row to load its results into the grid. A history banner replaces the progress bar, showing the session date, sources scanned, and item count.
|
||||
- **Latest scan** button in the banner jumps back to the most recent session.
|
||||
- Starting a new scan automatically exits history mode and switches to live SSE results.
|
||||
- All filters, dispositions, and exports work normally while browsing history — the Role filter and viewer-scope enforcement still apply.
|
||||
- Viewer tokens work with history mode: `GET /api/db/flagged?ref=N` applies scope filtering the same way as the live endpoint.
|
||||
|
||||
#### Delete items
|
||||
|
||||
Individual items can be deleted directly from their card (hover to reveal , confirm). Emails are moved to Deleted Items; files go to the recycle bin.
|
||||
@ -311,7 +322,7 @@ Scan results are persisted to `~/.gdprscanner/scanner.db` (SQLite) automatically
|
||||
| `dispositions` | Compliance officer decisions per item |
|
||||
| `scan_history` | Aggregated stats per scan for trend tracking |
|
||||
|
||||
**API endpoints:** `GET /api/db/stats`, `GET /api/db/trend`, `GET /api/db/scans`, `POST /api/db/subject`, `GET /api/db/overdue`, `POST /api/db/disposition`, `GET /api/db/disposition/<id>`
|
||||
**API endpoints:** `GET /api/db/stats`, `GET /api/db/trend`, `GET /api/db/scans`, `POST /api/db/subject`, `GET /api/db/overdue`, `POST /api/db/disposition`, `GET /api/db/disposition/<id>`, `GET /api/db/sessions`, `GET /api/db/flagged`
|
||||
|
||||
If `gdpr_db.py` is not present, the scanner falls back to JSON-only mode silently.
|
||||
|
||||
@ -573,7 +584,7 @@ pip install pytest
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
**112 tests across 4 modules — all expected to pass.**
|
||||
**128 tests across 4 modules — all expected to pass.**
|
||||
|
||||
| Module | Tests | Covers |
|
||||
|---|---|---|
|
||||
@ -610,8 +621,8 @@ See [SUGGESTIONS.md](SUGGESTIONS.md) for the full feature roadmap with implement
|
||||
| `scan_scheduler.py` | In-process APScheduler wrapper — multi-job scheduled scan engine |
|
||||
| `templates/index.html` | Single-page HTML shell — Jinja2 template. Two variables: `app_version`, `lang_json`. |
|
||||
| `static/style.css` | All application CSS — custom properties, layout, components, light/dark themes |
|
||||
| `static/js/state.js` | Shared mutable state module (`export const S`) — imported by all 11 feature modules |
|
||||
| `static/js/*.js` | 11 ES modules: `ui`, `log`, `users`, `auth`, `profiles`, `scan`, `results`, `sources`, `scheduler`, `connector`, `viewer` |
|
||||
| `static/js/state.js` | Shared mutable state module (`export const S`) — imported by all 12 feature modules |
|
||||
| `static/js/*.js` | 12 ES modules: `ui`, `log`, `users`, `auth`, `profiles`, `scan`, `results`, `sources`, `scheduler`, `connector`, `viewer`, `history` |
|
||||
| `static/app.js` | Archived JS monolith — no longer loaded |
|
||||
| `routes/__init__.py` | Blueprint package marker |
|
||||
| `routes/state.py` | Shared mutable state (`connector`, `flagged_items`, `LANG`, scan locks) — imported by all blueprints |
|
||||
@ -626,7 +637,7 @@ See [SUGGESTIONS.md](SUGGESTIONS.md) for the full feature roadmap with implement
|
||||
| `routes/email.py` | `/api/smtp/*` and `/api/send_report` |
|
||||
| `routes/database.py` | `/api/db/*`, `/api/admin/*`, `/api/preview`, `/api/thumb` |
|
||||
| `routes/export.py` | `/api/export_excel`, `/api/export_article30`, `/api/delete_bulk` |
|
||||
| `routes/viewer.py` | `/view`, `/api/viewer/tokens`, `/api/viewer/pin` — read-only viewer mode: token + PIN auth, share-link management, role-scoped tokens |
|
||||
| `routes/viewer.py` | `/view`, `/api/viewer/tokens`, `/api/viewer/pin` — read-only viewer mode: token + PIN auth, share-link management, role-scoped and user-scoped tokens |
|
||||
| `routes/app_routes.py` | `/api/about`, `/api/langs`, `/api/lang`, `/manual` |
|
||||
| `docs/manuals/MANUAL-EN.md` | End-user manual in English (15 sections) — served at `/manual?lang=en` |
|
||||
| `docs/manuals/MANUAL-DA.md` | End-user manual in Danish (15 sections) — served at `/manual?lang=da` |
|
||||
|
||||
36
TODO.md
36
TODO.md
@ -48,20 +48,40 @@ Fixed by adding `M365DriveNotFound(M365Error)` exception, raising it from `_get(
|
||||
|
||||
---
|
||||
|
||||
### #34 — User-scoped viewer tokens
|
||||
Extend viewer token scope from `{"role": "student"|"staff"}` to also support `{"user": "alice@school.dk"}`, filtering `flagged_items` by `account_id`. Lets a single employee see only their own flagged files.
|
||||
### #34 — User-scoped viewer tokens ✅
|
||||
Viewer token scope extended to `{"user": ["m365@…", "gws@…"], "display_name": "Alice Smith"}`, filtering `flagged_items` by `account_id IN (list)`. Lets a single employee see only their own flagged files across both M365 and Google Workspace.
|
||||
|
||||
**Infrastructure already in place:** `account_id` is an indexed column on `flagged_items`, populated for M365 (UPN) and Google (email). File-scan items have `account_id = ""` and won't appear in user-scoped views — document this in the token-creation UI.
|
||||
|
||||
**Changes needed:**
|
||||
1. Token creation UI — add a "specific user" option (email input) alongside the role dropdown
|
||||
2. `GET /api/db/flagged` — filter by `account_id` when `session["viewer_scope"].get("user")` is set (same pattern as existing role filter)
|
||||
3. Viewer header — show locked identity (similar to locked `#filterRole` for role-scoped tokens)
|
||||
**Implemented:**
|
||||
1. Scope format — `user` is a list of email strings (one per platform); `display_name` stored for UI display. Legacy single-string format coerced to list automatically.
|
||||
2. Token creation UI — scope-type selector (`All` / `Role` / `User`) reveals either the role select or a searchable name autocomplete. Autocomplete filters `S._allUsers` by display name or email; rows show name + both emails for dual-platform users. Selected user's full name fills the input; both emails stored in the scope.
|
||||
3. `GET /api/db/flagged` — filters `WHERE account_id IN (scope.user set)`, covering items from both platforms.
|
||||
4. Viewer header — `#viewerIdentityBadge` shows `scope.display_name` (full name); `#filterRole` hidden.
|
||||
5. `POST /api/viewer/tokens` — validates all entries in `scope.user` contain `@`; rejects combined `role`+`user` scope.
|
||||
6. Token list — shows display name badge; falls back to emails joined with `, `.
|
||||
|
||||
**Size:** Small · **Priority:** Medium
|
||||
|
||||
---
|
||||
|
||||
### Scan history browser ✅
|
||||
Review results from any past scan session without running a new scan.
|
||||
|
||||
**Implemented:**
|
||||
1. `gdpr_db.py` — `get_sessions(limit=50, window_seconds=300)`: groups `scans` rows into 300 s windows (same logic as `get_session_items`), returns newest-first list with `ref_scan_id` (highest scan_id in group), timestamps, sources set, flagged count, total scanned, and a delta flag.
|
||||
2. `gdpr_db.py` — `get_session_items(ref_scan_id=N)`: when `ref_scan_id` given, anchors the 300 s window to that scan's `started_at` instead of the latest scan.
|
||||
3. `GET /api/db/sessions` (new endpoint in `routes/database.py`) — returns the sessions list; viewer-mode sessions share the same `GET /api/db/flagged?ref=N` endpoint with scope enforcement intact.
|
||||
4. `static/js/history.js` (new module) — `loadHistorySession(refScanId)`, `openHistoryPicker()`, `closeHistoryPicker()`, `exitHistoryMode()`, `invalidateHistoryCache()` all exposed on `window.*`. Session cache (`_sessions`) invalidated by all `*_done` SSE handlers so the picker stays fresh after a new scan.
|
||||
5. History banner (`#historyBanner`) — shows session date/time, sources, item count; "Sessions" button opens picker dropdown; "Latest scan" button appears only when not already viewing the latest.
|
||||
6. Auto-load on page load — `results.js` calls `window.loadHistorySession?.(null)` when the SSE watchdog detects `!status.running`; `null` resolves to the latest completed session.
|
||||
7. Live→history transition: clicking a session in the picker sets `S._historyRefScanId` and shows the banner. History→live transition: `startScan()` calls `window.exitHistoryMode?.()`.
|
||||
|
||||
---
|
||||
|
||||
### Gmail SMTP error message when App Password already in use ✅
|
||||
The `535` auth error from Gmail fires for wrong app password, revoked app password, spaces in the 16-char code, and wrong username — all indistinguishable at the SMTP level. The old message unconditionally told users to "create an App Password", which is unhelpful when they already have one. Both the `smtp_test` and `send_report` error handlers now emit a Gmail-specific message that lists the three common causes and links to the App Password page for regeneration.
|
||||
|
||||
---
|
||||
|
||||
### #32 — Windowed mode for Profiles, Sources, and Settings ✗ Won't do
|
||||
The workflow is sequential (configure → scan → review), not parallel — there is no realistic scenario where a modal and the results grid need to be open simultaneously. The Sources panel is already visible in the sidebar. Option A (the least-work path) still loads the full 3800-line JS stack twice. Closed.
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# GDPR Scanner — Brugermanual
|
||||
|
||||
Version 1.6.15
|
||||
Version 1.6.17
|
||||
|
||||
---
|
||||
|
||||
@ -228,6 +228,18 @@ Brug filterbjælken over resultaterne til at indsnævre visningen:
|
||||
- **Risiko** — vis kun Art. 9, fotos, GPS eller høj-risiko-elementer.
|
||||
- **Rolle** — vis kun **Ansatte** eller **Elever**. Påvirker også eksporten: klikker du på **Excel** eller **Art.30**, mens en rolle er valgt, indeholder rapporten kun den pågældende gruppe, og filnavnet får suffikset `_elever` eller `_ansatte`.
|
||||
|
||||
### Gennemse tidligere scanningssessioner
|
||||
|
||||
Når en scanning er afsluttet, kan du gennemse resultaterne fra en tidligere scanningssession uden at køre en ny scanning.
|
||||
|
||||
- Klik på **Sessioner**-knappen i historikbanneret (der vises over resultatgitteret, når en scanning er afsluttet) for at åbne sessionsvælgeren.
|
||||
- Hver række viser dato og tidspunkt, hvilke kilder der blev scannet, og hvor mange elementer der blev fundet. Et **Δ**-mærkat angiver delta-scanninger; **Seneste** markerer den nyeste session.
|
||||
- Klik på en række for at indlæse den pågældende sessions resultater i gitteret. Et historikbanner erstatter statuslinjen med sessionens oplysninger.
|
||||
- Klik på **Seneste scanning** i banneret for at vende tilbage til den nyeste session.
|
||||
- Start af en ny scanning afslutter automatisk historiktilstanden og skifter til live-resultater.
|
||||
|
||||
Alle filtre, eksporter og dispositionsmærkning fungerer normalt, mens du gennemser tidligere sessioner.
|
||||
|
||||
---
|
||||
|
||||
## 6. Gennemgang og mærkning af fund
|
||||
@ -359,7 +371,10 @@ Du kan give en DPO, skoleleder eller compliance-koordinator skrivebeskyttet adga
|
||||
Klik på **🔗**-knappen øverst til højre i topbjælken for at åbne delingspanelet.
|
||||
|
||||
1. Angiv eventuelt en **Betegnelse** for at identificere, hvem linket er til (f.eks. "DPO-gennemgang april 2026").
|
||||
2. Vælg et **Rolleomfang** — **Alle roller**, **Ansatte** eller **Elever**. Et afgrænset link begrænser modtageren til elementer tilhørende den valgte rollegruppe; de kan ikke se andre elementer, og rollefilteret er låst i deres visning.
|
||||
2. Vælg et **Omfang**:
|
||||
- **Alle roller** — modtageren ser alle fundne elementer.
|
||||
- **Ansatte** / **Elever** — modtageren ser kun elementer tilhørende den valgte rollegruppe. Rollefilteret er låst i deres visning.
|
||||
- **Bruger** — modtageren ser kun elementer tilhørende en bestemt medarbejder. Vælg personen fra søgefeltet; scanneren matcher automatisk både deres M365- og Google Workspace-e-mailadresser. Brug denne mulighed, når du vil give en enkelt medarbejder adgang til sine egne scanningsresultater.
|
||||
3. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig.
|
||||
4. Klik på **Opret**. Der genereres et unikt link: `http://host:5100/view?token=…`
|
||||
5. Klik på **Kopiér** for at kopiere linket til udklipsholderen, og send det til gennemgangeren.
|
||||
@ -549,4 +564,4 @@ Ja. Brug **🔗 Del**-knappen til at oprette et skrivebeskyttet viewer-link elle
|
||||
|
||||
---
|
||||
|
||||
*GDPR Scanner v1.6.14 — teknisk opsætning og konfiguration: se README.md*
|
||||
*GDPR Scanner v1.6.17 — teknisk opsætning og konfiguration: se README.md*
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# GDPR Scanner — User Manual
|
||||
|
||||
Version 1.6.15
|
||||
Version 1.6.17
|
||||
|
||||
---
|
||||
|
||||
@ -228,6 +228,18 @@ Use the filter bar above the results to narrow down what you see:
|
||||
- **Risk dropdown** — show only Art. 9, photos, GPS, or high-risk items.
|
||||
- **Role dropdown** — show only **Ansatte** (staff) or **Elever** (students). Also scopes exports: clicking **Excel** or **Art.30** while a role is selected produces a report containing only that group, with `_elever` or `_ansatte` appended to the filename.
|
||||
|
||||
### Browsing past scan sessions
|
||||
|
||||
Once a scan has completed, you can review results from any earlier scan session without running a new scan.
|
||||
|
||||
- Click the **Sessions** button in the history banner (which appears above the results grid after a scan completes) to open the session picker.
|
||||
- Each row shows the date and time, which sources were scanned, and how many items were flagged. A **Δ** badge marks delta scans; **Latest** marks the most recent session.
|
||||
- Click any row to load that session's results into the grid. A history banner replaces the progress bar, showing the session details.
|
||||
- Click **Latest scan** in the banner to jump back to the most recent session.
|
||||
- Starting a new scan automatically exits history mode and switches back to live results.
|
||||
|
||||
All filters, exports, and disposition tagging work normally while browsing past sessions.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reviewing and Tagging Results
|
||||
@ -359,7 +371,10 @@ You can give a DPO, school principal, or compliance coordinator read-only access
|
||||
Click the **🔗** button in the top-right of the top bar to open the Share panel.
|
||||
|
||||
1. Optionally enter a **Label** to identify who the link is for (e.g. "DPO review April 2026").
|
||||
2. Choose a **Role scope** — **All roles**, **Ansatte** (staff only), or **Elever** (students only). A scoped link restricts the recipient to items belonging to that role group; they cannot see any other items, and the role filter is locked in their view.
|
||||
2. Choose a **Scope**:
|
||||
- **All roles** — the recipient sees all flagged items.
|
||||
- **Ansatte** / **Elever** — the recipient sees only items belonging to that role group. The role filter is locked in their view.
|
||||
- **User** — the recipient sees only the items belonging to a specific employee. Select the person from the search box; the scanner matches both their M365 and Google Workspace email addresses automatically. Use this when you want to give an individual employee access to their own scan results.
|
||||
3. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never.
|
||||
4. Click **Create**. A unique link is generated: `http://host:5100/view?token=…`
|
||||
5. Click **Copy** to copy the link to your clipboard, then send it to the reviewer.
|
||||
@ -549,4 +564,4 @@ Yes. Use the **🔗 Share** button to create a read-only viewer link or set a Vi
|
||||
|
||||
---
|
||||
|
||||
*GDPR Scanner v1.6.14 — for technical setup and configuration see README.md*
|
||||
*GDPR Scanner v1.6.17 — for technical setup and configuration see README.md*
|
||||
|
||||
48
gdpr_db.py
48
gdpr_db.py
@ -442,14 +442,60 @@ class ScanDB:
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
def get_session_items(self, window_seconds: int = 300) -> list[dict]:
|
||||
def get_sessions(self, limit: int = 50, window_seconds: int = 300) -> list[dict]:
|
||||
"""Return scan sessions (groups of concurrent scans) newest-first.
|
||||
|
||||
Concurrent M365 + Google + File scans each get their own scan_id but start
|
||||
within seconds of each other. This method groups them into logical sessions
|
||||
by the same 300-second window used by get_session_items().
|
||||
"""
|
||||
rows = self._connect().execute(
|
||||
"""SELECT id, started_at, finished_at, sources, flagged_count, total_scanned, delta
|
||||
FROM scans WHERE finished_at IS NOT NULL ORDER BY started_at ASC"""
|
||||
).fetchall()
|
||||
# Group consecutive scans started within window_seconds of each other
|
||||
groups: list[list[dict]] = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["sources"] = json.loads(d.get("sources") or "[]")
|
||||
if groups and d["started_at"] - groups[-1][0]["started_at"] <= window_seconds:
|
||||
groups[-1].append(d)
|
||||
else:
|
||||
groups.append([d])
|
||||
# Build session summaries newest-first
|
||||
sessions: list[dict] = []
|
||||
for grp in reversed(groups):
|
||||
ref = grp[-1] # highest scan_id in group (last in ASC order)
|
||||
sessions.append({
|
||||
"ref_scan_id": ref["id"],
|
||||
"started_at": grp[0]["started_at"],
|
||||
"finished_at": ref.get("finished_at"),
|
||||
"sources": list({s for g in grp for s in g["sources"]}),
|
||||
"flagged_count": sum(g["flagged_count"] or 0 for g in grp),
|
||||
"total_scanned": sum(g["total_scanned"] or 0 for g in grp),
|
||||
"delta": any(bool(g["delta"]) for g in grp),
|
||||
})
|
||||
if len(sessions) >= limit:
|
||||
break
|
||||
return sessions
|
||||
|
||||
def get_session_items(self, window_seconds: int = 300,
|
||||
ref_scan_id: int | None = None) -> list[dict]:
|
||||
"""Return flagged items from all scans in the same session as the latest scan.
|
||||
|
||||
A session is all scans whose started_at is within *window_seconds* of the
|
||||
most recently started completed scan. This captures concurrent M365, Google,
|
||||
and file scans which each create their own scan_id but start within seconds
|
||||
of each other.
|
||||
|
||||
If *ref_scan_id* is given, the session is anchored to that scan's started_at
|
||||
instead of the latest scan.
|
||||
"""
|
||||
if ref_scan_id:
|
||||
row = self._connect().execute(
|
||||
"SELECT started_at FROM scans WHERE id=?", (ref_scan_id,)
|
||||
).fetchone()
|
||||
else:
|
||||
row = self._connect().execute(
|
||||
"SELECT started_at FROM scans WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
17
lang/da.json
17
lang/da.json
@ -103,6 +103,13 @@
|
||||
"lbl_time": "Tid",
|
||||
"lbl_space": "Mellemrum",
|
||||
"lbl_loading": "Indlæser…",
|
||||
"history_lbl": "Historik",
|
||||
"history_items": "fund",
|
||||
"history_btn_sessions": "Sessioner",
|
||||
"history_btn_latest": "Seneste scanning",
|
||||
"history_picker_empty": "Ingen tidligere scanninger",
|
||||
"history_delta_badge": "Delta",
|
||||
"history_latest_badge": "Seneste",
|
||||
"lbl_blurred": "Sløret",
|
||||
"lbl_none": "Ingen",
|
||||
"lbl_scanner": "Scanner",
|
||||
@ -766,8 +773,14 @@
|
||||
"share_create_error": "Kunne ikke oprette link:",
|
||||
"share_revoke_confirm": "Tilbagekald dette link? Alle der bruger det, mister straks adgang.",
|
||||
"share_revoke_error": "Kunne ikke tilbagekalde:",
|
||||
"share_scope_lbl": "Rolleomfang",
|
||||
"share_scope_all": "Alle roller",
|
||||
"share_scope_lbl": "Omfang",
|
||||
"share_scope_all": "Alle",
|
||||
"share_scope_type_role": "Rolle",
|
||||
"share_scope_type_user": "Bruger",
|
||||
"share_scope_role_lbl": "Rolle",
|
||||
"share_scope_user_lbl": "Brugerens e-mail",
|
||||
"share_scope_user_placeholder": "alice@skole.dk",
|
||||
"share_scope_user_invalid": "Angiv venligst en gyldig e-mailadresse for brugeromfanget.",
|
||||
"share_scope_staff": "Ansatte",
|
||||
"share_scope_student": "Elever",
|
||||
|
||||
|
||||
17
lang/de.json
17
lang/de.json
@ -164,6 +164,13 @@
|
||||
"lbl_working": "Wird bearbeitet…",
|
||||
"lbl_stopping": "Wird gestoppt…",
|
||||
"lbl_loading": "Wird geladen…",
|
||||
"history_lbl": "Verlauf",
|
||||
"history_items": "Treffer",
|
||||
"history_btn_sessions": "Sessionen",
|
||||
"history_btn_latest": "Letzter Scan",
|
||||
"history_picker_empty": "Keine fr\u00fcheren Scans",
|
||||
"history_delta_badge": "Delta",
|
||||
"history_latest_badge": "Aktuell",
|
||||
"lbl_blurred": "Unscharf gemacht",
|
||||
"lbl_none": "Keine",
|
||||
"lbl_size": "Größe",
|
||||
@ -766,8 +773,14 @@
|
||||
"share_create_error": "Link konnte nicht erstellt werden:",
|
||||
"share_revoke_confirm": "Diesen Link widerrufen? Alle Nutzer verlieren sofort den Zugriff.",
|
||||
"share_revoke_error": "Widerrufen fehlgeschlagen:",
|
||||
"share_scope_lbl": "Rollenumfang",
|
||||
"share_scope_all": "Alle Rollen",
|
||||
"share_scope_lbl": "Bereich",
|
||||
"share_scope_all": "Alle",
|
||||
"share_scope_type_role": "Rolle",
|
||||
"share_scope_type_user": "Benutzer",
|
||||
"share_scope_role_lbl": "Rolle",
|
||||
"share_scope_user_lbl": "Benutzer-E-Mail",
|
||||
"share_scope_user_placeholder": "alice@schule.de",
|
||||
"share_scope_user_invalid": "Bitte gib eine gültige E-Mail-Adresse für den Benutzerbereich an.",
|
||||
"share_scope_staff": "Mitarbeitende",
|
||||
"share_scope_student": "Schüler",
|
||||
|
||||
|
||||
17
lang/en.json
17
lang/en.json
@ -103,6 +103,13 @@
|
||||
"lbl_time": "Time",
|
||||
"lbl_space": "Space",
|
||||
"lbl_loading": "Loading…",
|
||||
"history_lbl": "History",
|
||||
"history_items": "items",
|
||||
"history_btn_sessions": "Sessions",
|
||||
"history_btn_latest": "Latest scan",
|
||||
"history_picker_empty": "No past scans",
|
||||
"history_delta_badge": "Delta",
|
||||
"history_latest_badge": "Latest",
|
||||
"lbl_blurred": "Blurred",
|
||||
"lbl_none": "None",
|
||||
"lbl_scanner": "Scanner",
|
||||
@ -766,8 +773,14 @@
|
||||
"share_create_error": "Failed to create link:",
|
||||
"share_revoke_confirm": "Revoke this link? Anyone using it will immediately lose access.",
|
||||
"share_revoke_error": "Failed to revoke:",
|
||||
"share_scope_lbl": "Role scope",
|
||||
"share_scope_all": "All roles",
|
||||
"share_scope_lbl": "Scope",
|
||||
"share_scope_all": "All",
|
||||
"share_scope_type_role": "Role",
|
||||
"share_scope_type_user": "User",
|
||||
"share_scope_role_lbl": "Role",
|
||||
"share_scope_user_lbl": "User email",
|
||||
"share_scope_user_placeholder": "alice@school.dk",
|
||||
"share_scope_user_invalid": "Please enter a valid email address for the user scope.",
|
||||
"share_scope_staff": "Staff",
|
||||
"share_scope_student": "Students",
|
||||
|
||||
|
||||
@ -473,7 +473,7 @@ class M365Connector:
|
||||
msg = r.text[:200]
|
||||
raise M365PermissionError(path, msg)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
return r.json() if r.content else {}
|
||||
raise _requests.exceptions.RetryError(f"Gave up after {self._MAX_RETRIES} attempts: {url}")
|
||||
|
||||
def _get_bytes(self, url: str, _retry: bool = True) -> bytes:
|
||||
|
||||
@ -70,6 +70,13 @@ def db_scans():
|
||||
return jsonify(_get_db().scans_list())
|
||||
|
||||
|
||||
@bp.route("/api/db/sessions")
|
||||
def db_sessions():
|
||||
"""List scan sessions (grouped concurrent scans), newest first."""
|
||||
if not DB_OK: return jsonify([])
|
||||
return jsonify(_get_db().get_sessions())
|
||||
|
||||
|
||||
@bp.route("/api/db/subject", methods=["POST"])
|
||||
def db_subject_lookup():
|
||||
"""Find all items containing a given CPR number.
|
||||
@ -154,13 +161,22 @@ def db_flagged_items():
|
||||
from flask import session as _session
|
||||
scope = _session.get("viewer_scope", {})
|
||||
role_filt = scope.get("role", "") if isinstance(scope, dict) else ""
|
||||
items = _get_db().get_session_items()
|
||||
# user may be a list of emails (current) or a legacy single string
|
||||
raw_user = scope.get("user", "") if isinstance(scope, dict) else ""
|
||||
if isinstance(raw_user, list):
|
||||
user_filt = set(e.lower() for e in raw_user if e)
|
||||
else:
|
||||
user_filt = {raw_user.lower()} if raw_user else set()
|
||||
ref_scan_id = request.args.get("ref", type=int)
|
||||
items = _get_db().get_session_items(ref_scan_id=ref_scan_id)
|
||||
# Normalise JSON-encoded columns the same way scan_engine does for SSE cards
|
||||
import json as _json
|
||||
out = []
|
||||
for row in items:
|
||||
if role_filt and row.get("role", "") != role_filt:
|
||||
continue
|
||||
if user_filt and (row.get("account_id", "") or "").lower() not in user_filt:
|
||||
continue
|
||||
row["special_category"] = _json.loads(row.get("special_category") or "[]") if isinstance(row.get("special_category"), str) else row.get("special_category", [])
|
||||
row["exif"] = _json.loads(row.get("exif_json") or "{}") if isinstance(row.get("exif_json"), str) else row.get("exif", {})
|
||||
row.pop("exif_json", None)
|
||||
|
||||
@ -164,6 +164,12 @@ def smtp_test():
|
||||
use_tls = bool(saved.get("use_tls", True)) and not use_ssl
|
||||
|
||||
if not host:
|
||||
if graph_error_str:
|
||||
return jsonify({"error": (
|
||||
f"Microsoft Graph email failed: {graph_error_str}\n\n"
|
||||
"Make sure Mail.Send is added to your Azure app registration and admin consent has been granted:\n"
|
||||
"Azure AD → App registrations → [your app] → API permissions → Add → Microsoft Graph → Mail.Send → Grant admin consent."
|
||||
)}), 400
|
||||
return jsonify({"error": "No SMTP host configured. To send via Microsoft 365 Graph (no SMTP needed), add Mail.Send to your Azure app registration."}), 400
|
||||
|
||||
try:
|
||||
@ -210,9 +216,31 @@ def smtp_test():
|
||||
"(Users → Active users → [user] → Mail → Manage email apps → Authenticated SMTP), "
|
||||
"or add Mail.Send to your Azure app to use Graph instead.")
|
||||
elif (_personal_ms or _gmail_host) and _auth_err:
|
||||
provider = "Microsoft" if _personal_ms else "Google"
|
||||
url = "account.microsoft.com/security" if _personal_ms else "myaccount.google.com → Security → 2-Step Verification"
|
||||
err_str = (f"Authentication failed — {provider} blocks regular passwords for SMTP when MFA is enabled.\n\n"
|
||||
if _gmail_host:
|
||||
_gws_account = "@gmail.com" not in username.lower() and "@googlemail.com" not in username.lower()
|
||||
if _gws_account:
|
||||
err_str = ("Google Workspace SMTP authentication failed.\n\n"
|
||||
"Your account uses a custom domain via Google Workspace. "
|
||||
"SMTP access is controlled by your organisation's Google Workspace admin, not your personal account settings.\n\n"
|
||||
"Ask your Google Workspace admin to:\n"
|
||||
" • Enable 2-Step Verification for your account (required for App Passwords)\n"
|
||||
" • Allow users to manage their own App Passwords (Admin console → Security → 2-Step Verification)\n"
|
||||
" • Or configure SMTP relay: Admin console → Apps → Google Workspace → Gmail → Routing → SMTP relay service\n\n"
|
||||
"If App Passwords are available for your account, generate one at "
|
||||
"myaccount.google.com → Security → 2-Step Verification → App passwords "
|
||||
"and use it instead of your normal password.")
|
||||
else:
|
||||
err_str = ("Gmail SMTP authentication failed.\n\n"
|
||||
"Google requires an App Password for SMTP — your normal password will not work.\n\n"
|
||||
"If you are already using an App Password, check:\n"
|
||||
" • No spaces — the 16-character code must be entered without spaces\n"
|
||||
" • The App Password has not been revoked — generate a new one at "
|
||||
"myaccount.google.com → Security → 2-Step Verification → App passwords\n"
|
||||
" • The correct username (your full Gmail address, e.g. you@gmail.com)\n"
|
||||
" • Port 587 with STARTTLS, or port 465 with SSL")
|
||||
else:
|
||||
url = "account.microsoft.com/security"
|
||||
err_str = (f"Authentication failed — Microsoft blocks regular passwords for SMTP when MFA is enabled.\n\n"
|
||||
f"Fix: create an App Password at {url} → App passwords "
|
||||
f"and use that instead of your normal password.")
|
||||
elif graph_error_str:
|
||||
@ -295,9 +323,32 @@ def send_report():
|
||||
err = (f"{err}\n\nTip: Enable SMTP AUTH for this mailbox in the Microsoft 365 admin centre, "
|
||||
"or connect to M365 first so the scanner can send via Microsoft Graph instead.")
|
||||
elif (_personal_ms_2 or _gmail_2) and _auth_err_2:
|
||||
provider2 = "Microsoft" if _personal_ms_2 else "Google"
|
||||
url2 = "account.microsoft.com/security" if _personal_ms_2 else "myaccount.google.com → Security → 2-Step Verification"
|
||||
err = (f"Authentication failed — {provider2} blocks regular passwords for SMTP when MFA is enabled.\n\n"
|
||||
if _gmail_2:
|
||||
_uname2 = smtp_cfg.get("username", "").lower()
|
||||
_gws2 = "@gmail.com" not in _uname2 and "@googlemail.com" not in _uname2
|
||||
if _gws2:
|
||||
err = ("Google Workspace SMTP authentication failed.\n\n"
|
||||
"Your account uses a custom domain via Google Workspace. "
|
||||
"SMTP access is controlled by your organisation's Google Workspace admin, not your personal account settings.\n\n"
|
||||
"Ask your Google Workspace admin to:\n"
|
||||
" • Enable 2-Step Verification for your account (required for App Passwords)\n"
|
||||
" • Allow users to manage their own App Passwords (Admin console → Security → 2-Step Verification)\n"
|
||||
" • Or configure SMTP relay: Admin console → Apps → Google Workspace → Gmail → Routing → SMTP relay service\n\n"
|
||||
"If App Passwords are available for your account, generate one at "
|
||||
"myaccount.google.com → Security → 2-Step Verification → App passwords "
|
||||
"and use it instead of your normal password.")
|
||||
else:
|
||||
err = ("Gmail SMTP authentication failed.\n\n"
|
||||
"Google requires an App Password for SMTP — your normal password will not work.\n\n"
|
||||
"If you are already using an App Password, check:\n"
|
||||
" • No spaces — the 16-character code must be entered without spaces\n"
|
||||
" • The App Password has not been revoked — generate a new one at "
|
||||
"myaccount.google.com → Security → 2-Step Verification → App passwords\n"
|
||||
" • The correct username (your full Gmail address, e.g. you@gmail.com)\n"
|
||||
" • Port 587 with STARTTLS, or port 465 with SSL")
|
||||
else:
|
||||
url2 = "account.microsoft.com/security"
|
||||
err = (f"Authentication failed — Microsoft blocks regular passwords for SMTP when MFA is enabled.\n\n"
|
||||
f"Fix: create an App Password at {url2} → App passwords "
|
||||
f"and use that instead of your normal password.")
|
||||
return jsonify({"error": err}), 500
|
||||
|
||||
@ -316,13 +316,13 @@ def _run_google_scan(options: dict):
|
||||
broadcast("scan_error", {"file": f"Drive/{user_email}", "error": str(e)})
|
||||
|
||||
elapsed = _time.monotonic() - t_start
|
||||
broadcast("scan_done", {
|
||||
broadcast("google_scan_done", {
|
||||
"flagged_count": total_flagged,
|
||||
"total_scanned": total_scanned,
|
||||
"elapsed_seconds": round(elapsed, 1),
|
||||
})
|
||||
if _db and _db_scan_id:
|
||||
try:
|
||||
_db.end_scan(_db_scan_id, total_scanned, total_flagged)
|
||||
_db.finish_scan(_db_scan_id, total_scanned)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -78,9 +78,27 @@ def create_token():
|
||||
if not isinstance(raw_scope, dict):
|
||||
return jsonify({"error": "scope must be an object"}), 400
|
||||
role = str(raw_scope.get("role", "")).strip()
|
||||
# user may be a single email string (legacy) or a list of email strings
|
||||
raw_user = raw_scope.get("user", "")
|
||||
if isinstance(raw_user, str):
|
||||
user_emails = [raw_user.strip().lower()] if raw_user.strip() else []
|
||||
elif isinstance(raw_user, list):
|
||||
user_emails = [str(e).strip().lower() for e in raw_user if str(e).strip()]
|
||||
else:
|
||||
user_emails = []
|
||||
display_name = str(raw_scope.get("display_name", "")).strip()
|
||||
if role and user_emails:
|
||||
return jsonify({"error": "scope.role and scope.user are mutually exclusive"}), 400
|
||||
if role not in ("", "student", "staff"):
|
||||
return jsonify({"error": "scope.role must be '', 'student', or 'staff'"}), 400
|
||||
scope = {"role": role} if role else {}
|
||||
if user_emails and not all("@" in e for e in user_emails):
|
||||
return jsonify({"error": "scope.user entries must be valid email addresses"}), 400
|
||||
if user_emails:
|
||||
scope = {"user": user_emails, "display_name": display_name or user_emails[0]}
|
||||
elif role:
|
||||
scope = {"role": role}
|
||||
else:
|
||||
scope = {}
|
||||
entry = create_viewer_token(label=label, expires_days=expires_days, scope=scope)
|
||||
return jsonify(entry), 201
|
||||
|
||||
|
||||
@ -182,11 +182,11 @@ def run_file_scan(source: dict):
|
||||
_db_scan_id: int | None = None
|
||||
if _db:
|
||||
try:
|
||||
_db_scan_id = _db.begin_scan(
|
||||
sources=[source.get("source_type", "local")],
|
||||
user_count=0,
|
||||
options=source,
|
||||
)
|
||||
_db_scan_id = _db.begin_scan({
|
||||
"sources": [source.get("source_type", "local")],
|
||||
"user_ids": [],
|
||||
"options": source,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("[db] start_scan failed: %s", e)
|
||||
|
||||
|
||||
@ -165,6 +165,18 @@ if (window.VIEWER_MODE) {
|
||||
const _fr = document.getElementById('filterRole');
|
||||
if (_fr) { _fr.value = _scopeRole; _fr.style.display = 'none'; }
|
||||
}
|
||||
// If this token is user-scoped, show a locked identity badge and hide irrelevant filters.
|
||||
const _scopeUserRaw = (window.VIEWER_SCOPE || {}).user;
|
||||
if (_scopeUserRaw && (Array.isArray(_scopeUserRaw) ? _scopeUserRaw.length : _scopeUserRaw)) {
|
||||
const _fr = document.getElementById('filterRole');
|
||||
if (_fr) _fr.style.display = 'none';
|
||||
const _badge = document.getElementById('viewerIdentityBadge');
|
||||
if (_badge) {
|
||||
_badge.textContent = (window.VIEWER_SCOPE || {}).display_name
|
||||
|| (Array.isArray(_scopeUserRaw) ? _scopeUserRaw[0] : _scopeUserRaw);
|
||||
_badge.style.display = '';
|
||||
}
|
||||
}
|
||||
try { loadTrend(); } catch(e) {}
|
||||
} else {
|
||||
(async function() {
|
||||
|
||||
195
static/js/history.js
Normal file
195
static/js/history.js
Normal file
@ -0,0 +1,195 @@
|
||||
// ── Scan history browser ──────────────────────────────────────────────────────
|
||||
// Lets the user load and browse results from any past scan session without
|
||||
// running a new scan. Sessions are groups of concurrent M365 + Google + File
|
||||
// scans (same 300-second window used by get_session_items on the server).
|
||||
import { S } from './state.js';
|
||||
|
||||
const _SRC_LABELS = {
|
||||
email: 'Outlook',
|
||||
onedrive: 'OneDrive',
|
||||
sharepoint: 'SharePoint',
|
||||
teams: 'Teams',
|
||||
gmail: 'Gmail',
|
||||
gdrive: 'Google Drive',
|
||||
local: 'Lokal',
|
||||
smb: 'SMB',
|
||||
};
|
||||
|
||||
let _sessions = null; // cached list; null = stale
|
||||
let _latestRefScanId = null; // ref_scan_id of the newest session
|
||||
|
||||
// ── Session cache ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function _fetchSessions() {
|
||||
try {
|
||||
const r = await fetch('/api/db/sessions');
|
||||
_sessions = await r.json();
|
||||
} catch(e) {
|
||||
_sessions = [];
|
||||
}
|
||||
_latestRefScanId = _sessions.length ? _sessions[0].ref_scan_id : null;
|
||||
return _sessions;
|
||||
}
|
||||
|
||||
function invalidateHistoryCache() {
|
||||
_sessions = null;
|
||||
_latestRefScanId = null;
|
||||
}
|
||||
|
||||
// ── Load a session into the results grid ──────────────────────────────────────
|
||||
|
||||
async function loadHistorySession(refScanId) {
|
||||
// refScanId: null → latest session, positive int → specific session
|
||||
let resolvedRef = refScanId;
|
||||
if (resolvedRef === null) {
|
||||
const sessions = _sessions !== null ? _sessions : await _fetchSessions();
|
||||
if (!sessions.length) {
|
||||
// No scans in DB — nothing to show
|
||||
window.loadLastScanSummary?.();
|
||||
return;
|
||||
}
|
||||
resolvedRef = sessions[0].ref_scan_id;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/db/flagged?ref=' + resolvedRef);
|
||||
const items = await r.json();
|
||||
closeHistoryPicker();
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
S._historyRefScanId = null;
|
||||
_setHistoryBanner(false);
|
||||
window.loadLastScanSummary?.();
|
||||
return;
|
||||
}
|
||||
|
||||
S._historyRefScanId = resolvedRef;
|
||||
S.flaggedData = items;
|
||||
S.filteredData = [];
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const lastScan = document.getElementById('lastScanSummary');
|
||||
if (emptyState) emptyState.style.display = 'none';
|
||||
if (lastScan) lastScan.style.display = 'none';
|
||||
if (grid) { grid.innerHTML = ''; grid.style.display = 'grid'; }
|
||||
|
||||
window.renderGrid(items);
|
||||
try { window.markOverdueCards(); } catch(_) {}
|
||||
try { window.loadTrend(); } catch(_) {}
|
||||
_setHistoryBanner(true, resolvedRef);
|
||||
} catch(e) {
|
||||
console.error('[history] failed to load session:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banner ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _setHistoryBanner(visible, resolvedRef) {
|
||||
const banner = document.getElementById('historyBanner');
|
||||
const bannerTxt = document.getElementById('historyBannerText');
|
||||
const latestBtn = document.getElementById('historyLatestBtn');
|
||||
if (!banner) return;
|
||||
if (!visible) { banner.style.display = 'none'; return; }
|
||||
|
||||
const sess = (_sessions || []).find(s => s.ref_scan_id === resolvedRef);
|
||||
let label = '';
|
||||
if (sess) {
|
||||
const date = new Date(sess.started_at * 1000).toLocaleDateString(undefined,
|
||||
{day: 'numeric', month: 'short', year: 'numeric'});
|
||||
const time = new Date(sess.started_at * 1000).toLocaleTimeString(undefined,
|
||||
{hour: '2-digit', minute: '2-digit'});
|
||||
const srcStr = (sess.sources || []).map(s => _SRC_LABELS[s] || s).join(' · ');
|
||||
label = date + ' ' + time
|
||||
+ (srcStr ? ' · ' + srcStr : '')
|
||||
+ ' · ' + sess.flagged_count + ' ' + t('history_items', 'items');
|
||||
} else {
|
||||
label = S.flaggedData.length + ' ' + t('history_items', 'items');
|
||||
}
|
||||
|
||||
if (bannerTxt) bannerTxt.textContent = label;
|
||||
if (latestBtn) latestBtn.style.display = (resolvedRef !== _latestRefScanId) ? '' : 'none';
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
|
||||
function exitHistoryMode() {
|
||||
S._historyRefScanId = null;
|
||||
const banner = document.getElementById('historyBanner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
closeHistoryPicker();
|
||||
}
|
||||
|
||||
// ── Session picker dropdown ───────────────────────────────────────────────────
|
||||
|
||||
async function openHistoryPicker() {
|
||||
const drop = document.getElementById('historyDropdown');
|
||||
if (!drop) return;
|
||||
// Toggle
|
||||
if (drop.style.display !== 'none') { drop.style.display = 'none'; return; }
|
||||
|
||||
drop.innerHTML = '<div style="padding:10px 12px;font-size:12px;color:var(--muted)">'
|
||||
+ t('lbl_loading', 'Loading\u2026') + '</div>';
|
||||
drop.style.display = '';
|
||||
|
||||
const sessions = _sessions !== null ? _sessions : await _fetchSessions();
|
||||
|
||||
if (!sessions.length) {
|
||||
drop.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--muted);text-align:center">'
|
||||
+ t('history_picker_empty', 'No past scans') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
drop.innerHTML = '';
|
||||
sessions.forEach((sess, i) => {
|
||||
const date = new Date(sess.started_at * 1000).toLocaleDateString(undefined,
|
||||
{day: 'numeric', month: 'short', year: 'numeric'});
|
||||
const time = new Date(sess.started_at * 1000).toLocaleTimeString(undefined,
|
||||
{hour: '2-digit', minute: '2-digit'});
|
||||
const srcStr = (sess.sources || []).map(s => _SRC_LABELS[s] || s).join(' · ');
|
||||
const isActive = sess.ref_scan_id === S._historyRefScanId;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'padding:8px 12px;cursor:pointer'
|
||||
+ (i < sessions.length - 1 ? ';border-bottom:1px solid var(--border)' : '')
|
||||
+ (isActive ? ';background:var(--bg)' : '');
|
||||
row.innerHTML =
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">' +
|
||||
'<span style="font-size:12px;font-weight:500;color:var(--text)">' + date + '</span>' +
|
||||
'<span style="font-size:10px;color:var(--muted)">' + time + '</span>' +
|
||||
(sess.delta
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--muted);color:#fff;font-weight:600">'
|
||||
+ t('history_delta_badge', 'Delta') + '</span>'
|
||||
: '') +
|
||||
(i === 0
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600">'
|
||||
+ t('history_latest_badge', 'Latest') + '</span>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
'<div style="font-size:10px;color:var(--muted)">' +
|
||||
srcStr + ' \u00b7 ' + sess.flagged_count + ' ' + t('history_items', 'items') +
|
||||
'</div>';
|
||||
|
||||
row.addEventListener('mouseenter', () => { if (!isActive) row.style.background = 'var(--surface)'; });
|
||||
row.addEventListener('mouseleave', () => { row.style.background = isActive ? 'var(--bg)' : ''; });
|
||||
row.addEventListener('click', () => loadHistorySession(sess.ref_scan_id));
|
||||
drop.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function closeHistoryPicker() {
|
||||
const drop = document.getElementById('historyDropdown');
|
||||
if (drop) drop.style.display = 'none';
|
||||
}
|
||||
|
||||
// Close picker when clicking outside its container
|
||||
document.addEventListener('click', e => {
|
||||
const wrap = document.getElementById('historyPickerBtn')?.closest('[data-history-wrap]');
|
||||
if (wrap && !wrap.contains(e.target)) closeHistoryPicker();
|
||||
}, true);
|
||||
|
||||
// ── Window exports ────────────────────────────────────────────────────────────
|
||||
window.loadHistorySession = loadHistorySession;
|
||||
window.openHistoryPicker = openHistoryPicker;
|
||||
window.closeHistoryPicker = closeHistoryPicker;
|
||||
window.exitHistoryMode = exitHistoryMode;
|
||||
window.invalidateHistoryCache = invalidateHistoryCache;
|
||||
@ -502,7 +502,7 @@ function _sseWatchdog() {
|
||||
}
|
||||
if (!_initialStatusChecked) {
|
||||
_initialStatusChecked = true;
|
||||
if (!status.running) loadLastScanSummary();
|
||||
if (!status.running) window.loadHistorySession?.(null);
|
||||
}
|
||||
// When no scan is running, we still keep polling — the SSE connection
|
||||
// may have died and we need to detect the *next* scheduled scan.
|
||||
|
||||
@ -399,6 +399,7 @@ function _attachScanListeners(source) {
|
||||
if (d.delta) checkDeltaStatus();
|
||||
markOverdueCards();
|
||||
loadTrend();
|
||||
window.invalidateHistoryCache?.();
|
||||
});
|
||||
source.addEventListener('google_scan_done', function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
@ -427,6 +428,7 @@ function _attachScanListeners(source) {
|
||||
log('Google scan complete \u2014 ' + d.flagged_count + ' flagged of ' + d.total_scanned, 'ok');
|
||||
markOverdueCards();
|
||||
loadTrend();
|
||||
window.invalidateHistoryCache?.();
|
||||
});
|
||||
source.addEventListener('file_scan_done', function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
@ -452,9 +454,10 @@ function _attachScanListeners(source) {
|
||||
applyFilters();
|
||||
}
|
||||
}
|
||||
log('Bestandsscan fuldført \u2014 ' + d.flagged_count + ' flagget af ' + d.total_scanned, 'ok');
|
||||
log('Bestandsscan fuldf\u00f8rt \u2014 ' + d.flagged_count + ' flagget af ' + d.total_scanned, 'ok');
|
||||
markOverdueCards();
|
||||
loadTrend();
|
||||
window.invalidateHistoryCache?.();
|
||||
});
|
||||
// sse_replay_done marks end of buffer replay — log a note so the user knows
|
||||
// earlier events above were replayed from an already-running scan
|
||||
@ -520,6 +523,8 @@ function startScan(resume) {
|
||||
document.getElementById('statsSection').style.display = 'none';
|
||||
document.getElementById('statsPill').style.display = 'none';
|
||||
}
|
||||
// Exit history mode — live SSE takes over
|
||||
window.exitHistoryMode?.();
|
||||
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) {}
|
||||
|
||||
@ -28,4 +28,6 @@ export const S = {
|
||||
_pendingGoogleSources: null,
|
||||
// Sources
|
||||
_fileSources: [],
|
||||
// History browser
|
||||
_historyRefScanId: null, // null = live/SSE, number = viewing a past session
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// ── Viewer token management (#33) ─────────────────────────────────────────────
|
||||
// Share button → modal to create, copy, and revoke read-only viewer links.
|
||||
import { S } from './state.js';
|
||||
|
||||
async function _getShareBaseUrl() {
|
||||
// Use the machine's LAN IP so links work for remote users, not just localhost.
|
||||
@ -15,13 +16,126 @@ async function _getShareBaseUrl() {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
// ── User autocomplete for Share modal ────────────────────────────────────────
|
||||
|
||||
// Holds the resolved user when one is picked from the dropdown.
|
||||
// Cleared on modal reset or when the input is edited manually.
|
||||
let _selectedScopeUser = null; // { emails: string[], display_name: string }
|
||||
let _userAcInit = false;
|
||||
|
||||
function _initUserAutocomplete() {
|
||||
if (_userAcInit) return;
|
||||
_userAcInit = true;
|
||||
const input = document.getElementById('shareScopeUser');
|
||||
const drop = document.getElementById('shareScopeUserDropdown');
|
||||
if (!input || !drop) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
_selectedScopeUser = null; // user edited manually — discard dropdown selection
|
||||
_renderUserDropdown(input.value);
|
||||
});
|
||||
input.addEventListener('focus', () => _renderUserDropdown(input.value));
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { drop.style.display = 'none'; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); drop.querySelector('[data-uid]')?.focus(); }
|
||||
});
|
||||
drop.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { drop.style.display = 'none'; input.focus(); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); document.activeElement?.nextElementSibling?.focus(); }
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = document.activeElement?.previousElementSibling;
|
||||
prev ? prev.focus() : input.focus();
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
const el = document.activeElement;
|
||||
if (el?.dataset?.uid) _selectUser(parseInt(el.dataset.uid, 10));
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!document.getElementById('shareScopeUserWrap')?.contains(e.target))
|
||||
drop.style.display = 'none';
|
||||
}, true);
|
||||
}
|
||||
|
||||
function _renderUserDropdown(query) {
|
||||
const drop = document.getElementById('shareScopeUserDropdown');
|
||||
if (!drop) return;
|
||||
const users = S._allUsers;
|
||||
if (!users.length) { drop.style.display = 'none'; return; }
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
const matches = (q
|
||||
? users.filter(u =>
|
||||
(u.displayName || '').toLowerCase().includes(q) ||
|
||||
(u.email || '').toLowerCase().includes(q) ||
|
||||
(u.googleEmail || '').toLowerCase().includes(q))
|
||||
: users
|
||||
).slice(0, 8);
|
||||
if (!matches.length) { drop.style.display = 'none'; return; }
|
||||
drop.innerHTML = '';
|
||||
matches.forEach((u, i) => {
|
||||
const emails = [u.email, u.googleEmail].filter(Boolean);
|
||||
const emailLbl = emails.join(', ');
|
||||
const roleLbl = u.userRole === 'staff' ? t('share_scope_staff', 'Staff')
|
||||
: u.userRole === 'student' ? t('share_scope_student', 'Students')
|
||||
: '';
|
||||
const row = document.createElement('div');
|
||||
row.tabIndex = 0;
|
||||
row.dataset.uid = i; // index into matches; resolved in _selectUser
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:12px'
|
||||
+ (i < matches.length - 1 ? ';border-bottom:1px solid var(--border)' : '');
|
||||
row.innerHTML =
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||
(u.displayName || emails[0] || '') +
|
||||
(roleLbl ? ' <span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600">' + roleLbl + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + emailLbl + '</div>' +
|
||||
'</div>';
|
||||
row.addEventListener('mouseenter', () => row.style.background = 'var(--surface)');
|
||||
row.addEventListener('mouseleave', () => row.style.background = '');
|
||||
row.addEventListener('focus', () => row.style.background = 'var(--surface)');
|
||||
row.addEventListener('blur', () => row.style.background = '');
|
||||
row.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
_selectUser(u);
|
||||
});
|
||||
drop.appendChild(row);
|
||||
});
|
||||
drop.style.display = '';
|
||||
}
|
||||
|
||||
function _selectUser(u) {
|
||||
const input = document.getElementById('shareScopeUser');
|
||||
const drop = document.getElementById('shareScopeUserDropdown');
|
||||
const emails = [u.email, u.googleEmail].filter(Boolean);
|
||||
_selectedScopeUser = {
|
||||
emails: emails,
|
||||
display_name: u.displayName || emails[0] || '',
|
||||
};
|
||||
if (input) input.value = u.displayName || emails[0] || '';
|
||||
if (drop) drop.style.display = 'none';
|
||||
}
|
||||
|
||||
function _shareScopeTypeChanged() {
|
||||
const type = document.getElementById('shareScopeType')?.value || '';
|
||||
document.getElementById('shareScopeRoleWrap').style.display = type === 'role' ? '' : 'none';
|
||||
document.getElementById('shareScopeUserWrap').style.display = type === 'user' ? '' : 'none';
|
||||
if (type === 'user') _initUserAutocomplete();
|
||||
}
|
||||
|
||||
function openShareModal() {
|
||||
document.getElementById('shareBackdrop').classList.add('open');
|
||||
document.getElementById('shareNewLinkRow').style.display = 'none';
|
||||
document.getElementById('shareLabel').value = '';
|
||||
document.getElementById('shareExpiry').value = '30';
|
||||
const scopeSel = document.getElementById('shareScope');
|
||||
if (scopeSel) scopeSel.value = '';
|
||||
const scopeType = document.getElementById('shareScopeType');
|
||||
if (scopeType) { scopeType.value = ''; _shareScopeTypeChanged(); }
|
||||
_selectedScopeUser = null;
|
||||
const scopeUser = document.getElementById('shareScopeUser');
|
||||
if (scopeUser) scopeUser.value = '';
|
||||
const scopeDrop = document.getElementById('shareScopeUserDropdown');
|
||||
if (scopeDrop) scopeDrop.style.display = 'none';
|
||||
_renderTokenList();
|
||||
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
|
||||
const el = document.getElementById('sharePinStatus');
|
||||
@ -54,17 +168,23 @@ async function _renderTokenList() {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px';
|
||||
const roleVal = tok.scope?.role || '';
|
||||
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Elever')
|
||||
: roleVal === 'staff' ? t('share_scope_staff', 'Ansatte')
|
||||
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Students')
|
||||
: roleVal === 'staff' ? t('share_scope_staff', 'Staff')
|
||||
: '';
|
||||
const roleBadge = roleLbl
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle">' + roleLbl + '</span>'
|
||||
: '';
|
||||
const userScope = tok.scope?.user;
|
||||
const userLbl = tok.scope?.display_name
|
||||
|| (Array.isArray(userScope) ? userScope.join(', ') : (userScope || ''));
|
||||
const userBadge = userLbl
|
||||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--muted);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block">' + userLbl + '</span>'
|
||||
: '';
|
||||
row.innerHTML =
|
||||
'<div style="flex:1;min-width:0">' +
|
||||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||
(tok.label || '<span style="color:var(--muted);font-style:italic">' + t('share_unlabelled', 'Unlabelled') + '</span>') +
|
||||
roleBadge +
|
||||
roleBadge + userBadge +
|
||||
'</div>' +
|
||||
'<div style="font-size:10px;color:var(--muted);margin-top:1px">' +
|
||||
t('share_expires_prefix', 'Expires:') + ' ' + expires + ' · ' + t('share_last_used', 'Last used:') + ' ' + lastUsed +
|
||||
@ -84,10 +204,25 @@ async function _renderTokenList() {
|
||||
async function createShareLink() {
|
||||
const label = document.getElementById('shareLabel').value.trim();
|
||||
const expiry = document.getElementById('shareExpiry').value;
|
||||
const role = document.getElementById('shareScope')?.value || '';
|
||||
const scopeType = document.getElementById('shareScopeType')?.value || '';
|
||||
const body = {label};
|
||||
if (expiry) body.expires_days = parseInt(expiry);
|
||||
if (scopeType === 'role') {
|
||||
const role = document.getElementById('shareScope')?.value || '';
|
||||
if (role) body.scope = {role};
|
||||
} else if (scopeType === 'user') {
|
||||
if (_selectedScopeUser) {
|
||||
body.scope = { user: _selectedScopeUser.emails, display_name: _selectedScopeUser.display_name };
|
||||
} else {
|
||||
// Manual entry fallback — treat raw input as a single email
|
||||
const email = (document.getElementById('shareScopeUser')?.value || '').trim().toLowerCase();
|
||||
if (!email || !email.includes('@')) {
|
||||
alert(t('share_scope_user_invalid', 'Please enter a valid email address for the user scope.'));
|
||||
return;
|
||||
}
|
||||
body.scope = { user: [email], display_name: email };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/viewer/tokens', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
@ -240,6 +375,7 @@ async function stClearViewerPin() {
|
||||
}
|
||||
|
||||
// ── Window exports ────────────────────────────────────────────────────────────
|
||||
window._shareScopeTypeChanged = _shareScopeTypeChanged;
|
||||
window.openShareModal = openShareModal;
|
||||
window.closeShareModal = closeShareModal;
|
||||
window.createShareLink = createShareLink;
|
||||
|
||||
@ -327,6 +327,17 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
||||
<button onclick="clearCheckpointAndScan()" style="padding:3px 10px;border-radius:5px;background:none;border:1px solid var(--border);color:var(--muted);cursor:pointer;font-size:12px" data-i18n="m365_btn_start_fresh">Start fresh</button>
|
||||
</div>
|
||||
|
||||
<!-- History mode banner -->
|
||||
<div id="historyBanner" style="display:none;align-items:center;gap:10px;padding:6px 14px;background:var(--surface);border-bottom:1px solid var(--border);font-size:12px">
|
||||
<span style="font-size:11px;font-weight:600;color:var(--muted);flex-shrink:0" data-i18n="history_lbl">History</span>
|
||||
<span id="historyBannerText" style="flex:1;font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<div data-history-wrap style="position:relative;flex-shrink:0">
|
||||
<button id="historyPickerBtn" type="button" onclick="openHistoryPicker()" style="height:24px;padding:0 10px;background:none;border:1px solid var(--border);color:var(--muted);border-radius:4px;font-size:11px;cursor:pointer" data-i18n="history_btn_sessions">Sessions</button>
|
||||
<div id="historyDropdown" style="display:none;position:absolute;right:0;top:calc(100% + 4px);background:var(--surface);border:1px solid var(--border);border-radius:6px;z-index:9999;width:300px;max-height:260px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.25)"></div>
|
||||
</div>
|
||||
<button id="historyLatestBtn" type="button" onclick="loadHistorySession(null)" style="display:none;height:24px;padding:0 10px;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0" data-i18n="history_btn_latest">Latest scan</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar — full width, above grid + preview -->
|
||||
<div class="filter-bar" id="filterBar">
|
||||
<input type="text" id="filterSearch" data-i18n-placeholder="m365_filter_search" placeholder="Search…" oninput="applyFilters()">
|
||||
@ -362,6 +373,7 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
||||
<option value="1" data-i18n="m365_filter_special_only">⚠ Art. 9 only</option>
|
||||
<option value="photo" data-i18n="m365_filter_photo_only">📷 Photos / biometric</option>
|
||||
</select>
|
||||
<span id="viewerIdentityBadge" style="display:none;font-size:11px;padding:2px 8px;border-radius:10px;background:var(--muted);color:#fff;font-weight:600;white-space:nowrap;max-width:180px;overflow:hidden;text-overflow:ellipsis"></span>
|
||||
<select id="filterRole" onchange="applyFilters()" style="width:120px">
|
||||
<option value="" data-i18n="m365_filter_all_roles">All roles</option>
|
||||
<option value="staff" data-i18n="m365_filter_staff">Ansatte</option>
|
||||
@ -882,14 +894,26 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_label_lbl">Label (optional)</div>
|
||||
<input id="shareLabel" type="text" data-i18n-placeholder="share_label_placeholder" placeholder="e.g. DPO review 2026" style="width:100%;box-sizing:border-box;font-size:12px;padding:5px 8px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
</div>
|
||||
<div style="width:100px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_scope_lbl">Role scope</div>
|
||||
<select id="shareScope" style="width:100%;font-size:12px;padding:5px 6px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
<option value="" data-i18n="share_scope_all">All roles</option>
|
||||
<option value="staff" data-i18n="share_scope_staff">Ansatte</option>
|
||||
<option value="student" data-i18n="share_scope_student">Elever</option>
|
||||
<div style="width:120px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_scope_lbl">Scope</div>
|
||||
<select id="shareScopeType" onchange="_shareScopeTypeChanged()" style="width:100%;font-size:12px;padding:5px 6px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
<option value="" data-i18n="share_scope_all">All</option>
|
||||
<option value="role" data-i18n="share_scope_type_role">Role</option>
|
||||
<option value="user" data-i18n="share_scope_type_user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="shareScopeRoleWrap" style="width:110px;display:none">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_scope_role_lbl">Role</div>
|
||||
<select id="shareScope" style="width:100%;font-size:12px;padding:5px 6px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
<option value="staff" data-i18n="share_scope_staff">Staff</option>
|
||||
<option value="student" data-i18n="share_scope_student">Students</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="shareScopeUserWrap" style="flex:1.5;min-width:140px;display:none;position:relative">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_scope_user_lbl">User email</div>
|
||||
<input id="shareScopeUser" type="text" autocomplete="off" data-i18n-placeholder="share_scope_user_placeholder" placeholder="alice@school.dk" style="width:100%;box-sizing:border-box;font-size:12px;padding:5px 8px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
<div id="shareScopeUserDropdown" style="display:none;position:absolute;top:100%;left:0;right:0;margin-top:2px;background:var(--surface);border:1px solid var(--border);border-radius:6px;z-index:9999;max-height:220px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.3)"></div>
|
||||
</div>
|
||||
<div style="width:100px">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:3px" data-i18n="share_expires_in">Expires in</div>
|
||||
<select id="shareExpiry" style="width:100%;font-size:12px;padding:5px 6px;background:var(--surface);border:1px solid var(--border);border-radius:5px;color:var(--text)">
|
||||
@ -1303,5 +1327,6 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
||||
<script type="module" src="/static/js/scheduler.js"></script>
|
||||
<script type="module" src="/static/js/connector.js"></script>
|
||||
<script type="module" src="/static/js/viewer.js"></script>
|
||||
<script type="module" src="/static/js/history.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user