From 4ec5ae618cc939000c341926cbcbaa320faf8468 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Thu, 8 Oct 2026 07:14:52 -0400 Subject: [PATCH 1/3] fix(app): CSV downloads save instead of opening as a page In the app window a CSV link replaced the page with the CSV as text and left no way back: pywebview downloads only what WebKit can't show, or a link with the download attribute, and CSV is text. Every link to an attachment route now carries download, and a test holds every such route to it. Co-Authored-By: Claude Opus 5.5 --- ...load-opened-as-a-page-in-the-app-window.md | 20 +++++++ gamgui/web/templates/_records_meta.html | 2 +- gamgui/web/templates/audit.html | 2 +- gamgui/web/templates/onboarding.html | 2 +- tests/test_app_window_downloads.py | 57 +++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md create mode 100644 tests/test_app_window_downloads.py diff --git a/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md b/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md new file mode 100644 index 00000000..c4299c40 --- /dev/null +++ b/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md @@ -0,0 +1,20 @@ +# 2026-10-08 — A CSV download opened as a page in the app window, with no way back + +- **Symptom:** in the app, Builder → a read → **Download CSV** replaced the window's page with the CSV + shown as text. The window has no Back control (and pywebview disables the Delete-key back), so the + operator could only quit and relaunch. The Audit export and the onboarding template CSV did the same. +- **Cause:** pywebview's macOS backend downloads a response only when WebKit can't display it + (`decidePolicyForNavigationResponse` → `canShowMIMEType`) or when the link carries the HTML + `download` attribute (`decidePolicyForNavigationAction` → `shouldPerformDownload`). A CSV is + text, so WebKit displays it, ignoring `Content-Disposition: attachment`; none of the three links had + `download`. `ALLOW_DOWNLOADS = True` in `app.py` only enables those two paths. +- **Why not caught:** every test and the accessibility run use TestClient or headless Chrome, where an + attachment response downloads whatever the link says; CI builds the `.app` but never launches it, so + WKWebView's choice was never exercised. +- **Fix:** `download` on every link to an attachment route (`audit.html`, `onboarding.html`, + `_records_meta.html`); WebKit then hands it to pywebview's save panel, in the window's own session (the + token cookie included). +- **Prevention:** `tests/test_app_window_downloads.py` finds every GET route that sends + `Content-Disposition: attachment` and fails unless each is linked, and every link carries `download`. + It reasons from pywebview 6.2.1's source; that the save panel appears is unproven until the operator + clicks Download CSV in the rebuilt app. diff --git a/gamgui/web/templates/_records_meta.html b/gamgui/web/templates/_records_meta.html index 2c0b33db..26074428 100644 --- a/gamgui/web/templates/_records_meta.html +++ b/gamgui/web/templates/_records_meta.html @@ -1,4 +1,4 @@ {# How many rows match of how many, and the CSV of exactly those — in the result's tools row, and swapped there out of band each time the filter answers. #} {{ "{:,}".format(matched) }} of {{ "{:,}".format(total) }} row{{ "" if total == 1 else "s" }} -{% if filtered %}Download {{ "these " ~ "{:,}".format(matched) if matched > 1 else "this row" }} as CSV{% else %}Download CSV{% endif %} ↓ +{% if filtered %}Download {{ "these " ~ "{:,}".format(matched) if matched > 1 else "this row" }} as CSV{% else %}Download CSV{% endif %} ↓ diff --git a/gamgui/web/templates/audit.html b/gamgui/web/templates/audit.html index 33d6bde1..8da84f27 100644 --- a/gamgui/web/templates/audit.html +++ b/gamgui/web/templates/audit.html @@ -9,7 +9,7 @@

Audit log

{{ total }} action{{ "s" if total != 1 else "" }} logged · {{ failures }} failure{{ "s" if failures != 1 else "" }}

- Export CSV ↓ + Export CSV ↓

Read-only — this screen only reads the local audit log; it never calls gam. Export downloads the full log (filters aren't applied to the download in v1).

diff --git a/gamgui/web/templates/onboarding.html b/gamgui/web/templates/onboarding.html index 305fa619..68bbda15 100644 --- a/gamgui/web/templates/onboarding.html +++ b/gamgui/web/templates/onboarding.html @@ -107,7 +107,7 @@

Add / edit a role

Import a CSV of hires

One row per hire; the role column picks the template (its steps, signature, org unit, groups & calendars). Columns: role, name, email, manager, assignee, create_account, first, last, send_welcome, notify. Leave notify blank to put that hire's temp password on the printable sheet, or set it to an email and GAM sends the sign-in info there directly.

- Download a template CSV ↓ + Download a template CSV ↓
diff --git a/tests/test_app_window_downloads.py b/tests/test_app_window_downloads.py new file mode 100644 index 00000000..93bac52b --- /dev/null +++ b/tests/test_app_window_downloads.py @@ -0,0 +1,57 @@ +"""Every file download must download in the app's own window, not open as a page in it. + +The app shows pages in pywebview's WKWebView. Its macOS backend downloads a response only when WebKit +can't display it, or when the link carries the HTML `download` attribute +(`webView:decidePolicyForNavigationAction:` → `shouldPerformDownload`). A CSV is text, so WebKit +displays it: without the attribute the window navigated to the CSV, ignoring +`Content-Disposition: attachment`, and the window has no Back (failure-log 2026-10-08). A browser +downloads either way, which is why only the app showed it. These hold every attachment route to a +`download` link. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from gamgui.web.server import TEMPLATES + +TEMPLATE_DIR = Path(TEMPLATES.env.loader.searchpath[0]) +ROUTES_DIR = TEMPLATE_DIR.parent / "routes" +# How a template names each download route: a literal path, or the variable that carries it. +HREFS = {"/builder/export.csv": "{{ csv_url }}"} + + +def _attachment_paths() -> set: + """GET paths whose handler sends Content-Disposition: attachment (a file to save, not a page).""" + paths = set() + for path in ROUTES_DIR.glob("*.py"): + src = path.read_text() + prefix = (re.search(r'APIRouter\(prefix="([^"]*)"', src) or [None, ""])[1] + for m in re.finditer(r'@router\.get\("([^"]*)"[^)]*\)\s*\n(?:async )?def \w+\([^)]*\)[^:]*:(.*?)(?=\n@router|\Z)', + src, re.S): + if "attachment" in m.group(2): + paths.add(prefix + m.group(1)) + return paths + + +def _anchors() -> list: + return [(p.name, m.group(0)) for p in TEMPLATE_DIR.glob("*.html") for m in re.finditer(r"]*>", p.read_text())] + + +def test_the_attachment_routes_are_the_ones_we_think(): + # A new download route must be added here and linked with `download`; this catches it appearing. + assert _attachment_paths() == {"/audit/export.csv", "/builder/export.csv", "/onboard/bulk/template.csv"} + + +def test_every_link_to_a_download_route_carries_the_download_attribute(): + missing, linked = [], set() + for route in _attachment_paths(): + href = HREFS.get(route, route) + for name, tag in _anchors(): + if f'href="{href}' in tag: + linked.add(route) + if not re.search(r"\sdownload[\s>=]", tag): + missing.append(f"{name}: {tag[:90]}") + assert not missing, "a CSV link opens as a page in the app window without `download`:\n" + "\n".join(missing) + assert linked == _attachment_paths() # every route is linked somewhere From d45da3b3bba9597cfde60b7b005c3cc8262b3ab0 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Thu, 8 Oct 2026 07:14:52 -0400 Subject: [PATCH 2/3] docs: record the first live account delete The offboarded leaver was deleted from the user page after the transfer completed (GAM 7.48.22). The README row, the offboarding runbook and the plan now say so. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/domains/lifecycle-offboarding.md | 3 +-- docs/plans/2026-09-23-ten-out-of-ten.md | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a0524e72..53546a01 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ for the Builder's External-only filter, and reading the connected admin and its | Sign out everywhere | Users, Builder | not yet | | Revoke access: app passwords, backup codes, OAuth tokens, and sign out (`deprovision signout`) | Offboarding | **confirmed** (the first live offboarding, 2026-10-07, GAM 7.48.14) | | Suspend / unsuspend | Users (both), Builder (suspend) | not yet | -| Delete an account | Users, Builder | not yet | +| Delete an account | Users, Builder | **confirmed** (2026-10-08, GAM 7.48.22, from the user page: the offboarded leaver, after the transfer completed) | | Undelete an account | Builder | not yet | | Transfer Drive + calendar data | Offboarding, Builder | **confirmed**: as two calls, and the single call offboarding makes (`drive,calendar … all`) ran (the first live offboarding, 2026-10-07, GAM 7.48.14), and the operator checked the effects in Google afterwards: the transfer completed | | Remove the leaver from everyone's calendars | Offboarding | **confirmed** the shape (the first live offboarding, 2026-10-07, GAM 7.48.14): GAM visited every user and ended, as expected, on the leaver's own calendar ("Cannot change your own access level", exit 50, tolerated); the operator checked the effects in Google afterwards | diff --git a/docs/domains/lifecycle-offboarding.md b/docs/domains/lifecycle-offboarding.md index 8eebf632..d67636f9 100644 --- a/docs/domains/lifecycle-offboarding.md +++ b/docs/domains/lifecycle-offboarding.md @@ -326,8 +326,7 @@ on **every** offboarding and is tolerated as `OWN_ACL` (the mock's default sweep `SWEEPCLEAN` keeps the exit-0 path for a leaver suspended first). The operator then worked through the after-run checks in Google and reported every effect as expected: the transfer completed, the manager has the mailbox, an outside sender gets the auto-reply, the sweep and the reminder did their -part. Still unproven live: the reminder with an invitee (`sendupdates all`; this run had none) and -`delete_user`. +part. The account delete followed live on 2026-10-08 (GAM 7.48.22, from the user page, after the transfer completed): `delete user` ok. Still unproven live: the reminder with an invitee (`sendupdates all`; this run had none). ## First live run checklist Offboarding a real user is the live test (plan D8; first run 2026-10-07, all eight `ok` — see diff --git a/docs/plans/2026-09-23-ten-out-of-ten.md b/docs/plans/2026-09-23-ten-out-of-ten.md index f4b67058..2ef69376 100644 --- a/docs/plans/2026-09-23-ten-out-of-ten.md +++ b/docs/plans/2026-09-23-ten-out-of-ten.md @@ -222,7 +222,7 @@ plan's Phase 0 F1–F3. scope, and D6 says none for now. - **Phase 8** — **done** for offboarding (D8). The operator's first real offboarding ran 2026-10-07, all eight steps `ok`, and its effects checked in Google; README "Live verification status" and the - offboarding runbook record it. Still open: the reminder with an invitee, and the account delete. + offboarding runbook record it; the account delete ran live 2026-10-08. Still open: the reminder with an invitee. **Beyond the plan**, also on the branch (each has a failure-log entry): every confirm step runs what its preview held, under a single-use token — offboarding, signatures, onboarding, bulk department, From c8cf34b0f9041ca51cfcbaa571555fcee65fac31 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Thu, 8 Oct 2026 07:20:58 -0400 Subject: [PATCH 3/3] docs: the app-window download fix is confirmed in the app no-failure-log: records the operator's check of this branch's own fix. Co-Authored-By: Claude Opus 5.5 --- ...-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md b/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md index c4299c40..d075fec2 100644 --- a/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md +++ b/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md @@ -16,5 +16,4 @@ token cookie included). - **Prevention:** `tests/test_app_window_downloads.py` finds every GET route that sends `Content-Disposition: attachment` and fails unless each is linked, and every link carries `download`. - It reasons from pywebview 6.2.1's source; that the save panel appears is unproven until the operator - clicks Download CSV in the rebuilt app. + Confirmed in the rebuilt app the same day: Download CSV opens the save panel and the page stays put.