diff --git a/README.md b/README.md index a0524e7..53546a0 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 8eebf63..d67636f 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/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 0000000..d075fec --- /dev/null +++ b/docs/failure-log/2026-10-08-a-csv-download-opened-as-a-page-in-the-app-window.md @@ -0,0 +1,19 @@ +# 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`. + Confirmed in the rebuilt app the same day: Download CSV opens the save panel and the page stays put. 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 f4b6705..2ef6937 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, diff --git a/gamgui/web/templates/_records_meta.html b/gamgui/web/templates/_records_meta.html index 2c0b33d..2607442 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 33d6bde..8da84f2 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 305fa61..68bbda1 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 0000000..93bac52 --- /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