Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 1 addition & 2 deletions docs/domains/lifecycle-offboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/plans/2026-09-23-ten-out-of-ten.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion gamgui/web/templates/_records_meta.html
Original file line number Diff line number Diff line change
@@ -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. #}
<span id="rt-count" data-announce="rt-count" class="text-brand-grayink"{% if oob %} hx-swap-oob="true"{% endif %}>{{ "{:,}".format(matched) }} of {{ "{:,}".format(total) }} row{{ "" if total == 1 else "s" }}</span>
<a id="rt-csv" href="{{ csv_url }}" class="font-medium text-brand-blueink hover:text-brand-black"{% if oob %} hx-swap-oob="true"{% endif %}{% if not matched %} hidden{% endif %}>{% if filtered %}Download {{ "these " ~ "{:,}".format(matched) if matched > 1 else "this row" }} as CSV{% else %}Download CSV{% endif %} ↓</a>
<a id="rt-csv" href="{{ csv_url }}" download class="font-medium text-brand-blueink hover:text-brand-black"{% if oob %} hx-swap-oob="true"{% endif %}{% if not matched %} hidden{% endif %}>{% if filtered %}Download {{ "these " ~ "{:,}".format(matched) if matched > 1 else "this row" }} as CSV{% else %}Download CSV{% endif %} ↓</a>
2 changes: 1 addition & 1 deletion gamgui/web/templates/audit.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<h1 class="mt-2 font-serif text-3xl text-brand-black tracking-tight">Audit log</h1>
<p class="text-brand-blueink mt-1 text-sm">{{ total }} action{{ "s" if total != 1 else "" }} logged · {{ failures }} failure{{ "s" if failures != 1 else "" }}</p>
</div>
<a href="/audit/export.csv" class="text-sm font-medium text-brand-blueink hover:text-brand-black">Export CSV ↓</a>
<a href="/audit/export.csv" download class="text-sm font-medium text-brand-blueink hover:text-brand-black">Export CSV ↓</a>
</div>
<p class="text-xs text-brand-grayink -mt-2">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).</p>

Expand Down
2 changes: 1 addition & 1 deletion gamgui/web/templates/onboarding.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ <h2 class="eyebrow text-brand-grayink">Add / edit a role</h2>
<h2 class="eyebrow text-brand-grayink">Import a CSV of hires</h2>
<p class="mt-1 text-xs text-brand-grayink">One row per hire; the <span class="font-mono">role</span> column picks the template (its steps, signature, org unit, groups &amp; calendars). Columns: <span class="font-mono">role, name, email, manager, assignee, create_account, first, last, send_welcome, notify</span>.
Leave <span class="font-mono">notify</span> 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.</p>
<a href="/onboard/bulk/template.csv" class="mt-1 inline-block text-xs text-brand-blueink hover:underline">Download a template CSV ↓</a>
<a href="/onboard/bulk/template.csv" download class="mt-1 inline-block text-xs text-brand-blueink hover:underline">Download a template CSV ↓</a>
</div>
<form hx-post="/onboard/bulk/preview" hx-target="#onboard-bulk-result" hx-swap="innerHTML"
hx-encoding="multipart/form-data" class="flex flex-wrap items-center gap-2">
Expand Down
57 changes: 57 additions & 0 deletions tests/test_app_window_downloads.py
Original file line number Diff line number Diff line change
@@ -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"<a\s[^>]*>", 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
Loading