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:
1 parent
e64d7eb958
commit
c9aab19a97
24 files changed
+753
-70
No files matched your search
+17
-1
@@ -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)
|
||||
|
||||
+61
-10
@@ -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,11 +216,33 @@ 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"
|
||||
f"Fix: create an App Password at {url} → App passwords "
|
||||
f"and use that instead of your normal password.")
|
||||
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:
|
||||
err_str = f"SMTP: {err_str} | Graph also unavailable (Mail.Send not granted)"
|
||||
return jsonify({"error": err_str}), 200
|
||||
@@ -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"
|
||||
f"Fix: create an App Password at {url2} → App passwords "
|
||||
f"and use that instead of your normal password.")
|
||||
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
|
||||
+19
-1
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user