Give identity its due: native enrollment, honest deletion, and sign-in that stays home

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
@@ -898,3 +898,218 @@ run on their own, and both pass:
system ones (`disks-contract`, `snapshots-contract`, `containers-contract`),
then the harness ones (`settings-search-contract`, `health-ui-contract`), and
`settings-pages-contract` last, as before.
## Phase 11 (Users & Accounts) — append below
Spec: `2026-08-24-users-accounts-redesign.md`. Users grew a profile hero with a
stock-avatar gallery and a Remove, a password flow with a strength meter, a
live-validating add-user form, a keep-files deletion choice, and per-user
management (type / reset password / unlock). Fingerprint enrollment stopped
handing people to GNOME's Users panel and became native — fprintd's
`EnrollStart` driven from `panama-fingerprint`, streaming one JSON line per
touch — and the card learned to appear in the state that needs acting on rather
than only when a reader is attached. Online Accounts gained native Nextcloud and
IMAP forms, confirmed removal, and an availability that no longer collapses on a
failed write.
Three agents edited the tree concurrently. Everything below was reconciled
against the landed files at the end of the phase, not against the spec's pinned
shapes: three of the needles moved during the phase (the card-visibility
expression went into the service as `cardVisible`, the add-user field became
`LiveFieldRow`, and the fingerprint helper became Python) and each is pinned
where it actually landed.
### New contracts (1)
`quickshell/online-accounts-contract`. The README count line moves
**171 → 172**; `setup/readme-contract` was run and passes ("172 contracts, as
documented").
### Run and passing
- **`quickshell/fingerprint-contract` — RUN END TO END, PASS.** The whole file
was reworked, because two of its needles inverted. Hermetic: `env -i`, a stub
directory first on `PATH` (asserted, before the helper runs, that
fprintd-list, authselect, sudo and pkexec all resolve inside it), both bus
addresses pointed at sockets that do not exist, `PANAMA_PATH` pointed at a
recording `panama-sudo` that runs **nothing**, and enrollment replayed from
`PANAMA_FINGERPRINT_FIXTURE` with the calls it stood in for landing in
`PANAMA_FINGERPRINT_LOG`. What it pins:
- **The inverted needle.** `openGnomePanel("system", "users")` is now a
failure rather than a requirement, checked across every settings page.
`gnome-handoff-contract` lost its `UsersPage.qml:system-users` exception at
the same time; this is the other half of that, said from the page's side.
- **The stuck state, from the data.** With the stub fprintd reporting no
devices and the stub authselect reporting `with-fingerprint`, `status` must
still answer `unlockFeatureEnabled: true` — the combination that used to be
unrepresentable, because the feature was only read when fprintd answered.
This machine is in that state, which is how it was found.
- **Card visibility followed one level of indirection.** The expression moved
into `services/Fingerprint.qml` as `cardVisible: readerPresent ||
unlockFeatureEnabled`; the contract accepts either the page spelling it out
or the page binding to a service property that does, and resolves the second
rather than forbidding it.
- **The enrollment stream as a stream.** A five-stage fixture with a retry in
the middle produces nine lines; every one must be a JSON object on its own,
the counter monotonic and inside `0..total`, `total` constant, and the
retried touch must **not** have advanced the counter — a panel that counts a
failed touch promises a finish that never arrives. The terminal line says
which way it ended.
- **Claim and Release bracket everything**, read from the call log: first call
`Claim`, last call `Release`, `EnrollStart` after the claim — including on a
failed enrollment, because a reader left claimed refuses the next attempt
with "busy with something else" forever.
- **A terminal failure is reported in words**, not in fprintd's vocabulary
(`enroll-duplicate` → "That finger is already enrolled").
- **Nonsense is refused before the reader is touched**: four bad finger names
against both `enroll` and `remove`, each of which must leave the call log
empty.
- **`remove` deletes the finger it was asked about**, and `remove-all` uses
`DeleteEnrolledFingers2` with no arguments — its predecessor took a user
name, which is how a typo deletes somebody else's prints.
- **With no fixture and no bus it still answers in its own shape**: the page
reads this stream line by line, and a traceback leaves an enrollment panel
open forever.
- **The stated reason survived the rewrite to Python**: the escalation must go
through `panama-sudo --reason …`, and a bare `sudo`/`pkexec` in the argv log
is a failure.
- **Finger vocabulary**: a single id elsewhere is a default choice and is
allowed; two in one file is a copy of the list, and the copy is what falls
behind.
- **`quickshell/user-accounts-contract` — RUN END TO END, PASS.** Its snapshot
half still reads the live account service, which is read-only; every verb that
would change something is pinned from the source and never invoked
(`set-icon gib ""` really does clear the avatar). New halves:
- **`reset-password` carries no password material**, by AST: it must call
`SetPasswordMode`, must resolve its mode argument to **1** (through the
named constant, so the meaning survives), must not call `SetPassword`, and
its body — docstring excluded, because the docstring is allowed to say the
word — must not mention stdin, openssl, crypt or passwd.
- **keep-files end to end, as three separate links**, because any one of them
can invert on its own: the helper derives `DeleteUser`'s boolean from its
own argument (a literal there is the bug), the service's ternary is checked
against the *polarity of its own parameter name* (Panama flipped this once
already, `removeFiles` → `keepFiles`), and the page passes a variable rather
than a constant. Both vocabularies are exercised — `keep`/`remove` and the
legacy `keep-files`/`remove-files` — by reading which refusal comes back,
so a vocabulary that stopped being understood shows up as the wrong message
rather than as no message.
- **`removeIcon` sends an empty path**, and `set_icon` recognizes it rather
than handing "" to GdkPixbuf, which would fail and leave the avatar.
- **The live-validation regression, two ways.** Structurally: the property the
form's user-name test reads must be assigned from a per-keystroke handler,
and the component that assigns it must not be `TextFieldRow`. Bluntly: the
page carries no `TextFieldRow` at all. The page's regex is compared
character for character against the helper's `USERNAME`, and the 31-cap must
appear in the page's own text — a form that accepts a name accountsservice
then refuses has no way to say why.
- **The last administrator is refused helper-side too**, not only by the page
that offers the control.
- **Stock avatars**: shape read from a real run (`{name, path}`, absolute
paths, empty list legitimate).
- **`quickshell/gnome-handoff-contract` — RUN, PASS** (9 handoffs, 39 pages).
The `UsersPage.qml:system-users` exception is deleted; the
`OnlineAccountsPage.qml:online-accounts` one stays with its reason rewritten
to name OAuth specifically — libgoa-backend ships without a GIR binding, so
the provider's own dialog is the only way to obtain a token, and Nextcloud and
IMAP are added on the page itself.
- **`setup/readme-contract` — RUN, PASS** after the count line moved.
### Run with one half relaxed (1)
- **`quickshell/online-accounts-contract` — everything passes except six
input-validation cases**, which are pinned as the spec asks and which the
landed `panama-accounts` does not yet satisfy. See "Still open" below. Run
with those six removed, the contract passes end to end. It is hermetic: `env
-i`, a stub directory first on `PATH` (gdbus/busctl/dbus-send/
gnome-control-center all resolve there, asserted before running), a `gi`
stand-in on `PYTHONPATH` whose `require_version` always raises — so if the
fixture seam were ever removed the contract would stop working rather than
quietly start editing the session's accounts — and both bus addresses pointed
at sockets that do not exist. Accounts come from `PANAMA_ACCOUNTS_FIXTURE`;
what each verb would have done lands in `PANAMA_ACCOUNTS_LOG`. What it pins:
- **Availability is not an error**, at the definition and at every assignment:
no line that decides `available` may mention a write error, and the service
must keep at least two error strings, or "GOA did not answer" and "that one
change did not happen" are the same fact. This is the regression where one
refused toggle replaced four working accounts with "not available".
- **The same thing from the data**: a refused add must come back with the
error *and* the full account list, because the page draws its list from the
same answer that carries the message.
- **The password on stdin, proved rather than assumed.** The log records the
password's **length**, never the thing itself, so the sentinel can be
checked for everywhere it might have gone (stdout, stderr, the log, the
scratch home) while `passwordBytes` proves it was read at all rather than
dropped.
- **The listing cannot carry a credential**, read from `describe`'s own dict
keys and from the GOA properties it reads, so a password property added
later is caught at the source rather than after it reaches the page.
- **An added account is proved, not assumed**: `call_add_account_sync` must be
followed by `call_ensure_credentials_sync`, and `verify` must be able to
`call_remove_sync` — GOA does not check what it is handed, so without that
step a mistyped password produces an account that exists, looks correct, and
never syncs, which is the exact failure this page exists to explain.
- **Provider types**: `owncloud` (the fork kept the id) and `imap_smtp`. A
provider type GOA does not know is not an error — AddAccount is simply never
offered it.
- **Both names for the listing** (`list` and `snapshot`) answer the same
thing, and reading the list changes nothing.
- **Removal carries the path intact** — removing the wrong account is
unrecoverable and looks like a success — and is confirmation-gated on the
page, read from the object block around the call rather than from a
`confirming` property declared anywhere in the file.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — nine entries added, all routing to leaves that
exist in `SettingsRoutes`: **Fingerprint**, **Enroll a fingerprint**, **Delete
a user**, **Account type**, **Reset a password** → `users`; **Nextcloud**,
**Google account**, **Add a mail account**, **Remove an account** →
`accounts`. Checked by evaluating the array: 163 entries, no duplicate labels,
every `page` a real leaf, and none of `settings-search-contract`'s 21 ranked
queries or 9 leaf-routing queries changes its top result — the only new entry
any of them touches at all is Fingerprint, which matches `lock` through
"Unlock" and sits far below the existing first hit.
- No settings docs regenerated: this phase adds no schema keys. Every new
setting is system state (accountsservice, fprintd, authselect, GOA), not a
Panama preference — verified by reading the diff.
### Still open before the run
- **`online-accounts-contract` fails six validation cases against the landed
helper**, and they are left pinned rather than relaxed, because the spec asks
for them and each produces an account that exists, looks right in the list,
and never syncs. `nextcloud_uris` accepts anything with a host once `https://`
is prepended, and `imap_account` accepts any non-empty string as a server:
- `add-nextcloud` accepts `not a url`, `https://cloud.example.org; reboot`,
and `https://cloud example org`;
- `add-imap` accepts `imap example com`, `imap.example.com; reboot`, and
`-imap.example.com` as either server.
A hostname check in `nextcloud_uris` and one applied to both hosts in
`imap_account` closes all six. `''`, `ftp://…` and `https://` (no host) are
already refused, and every other assertion in the file passes.
- **`settings-search-contract` has not been run**: it starts a Quickshell
harness. The nine new entries were checked statically as described above.
- **`settings-pages-contract`, `settings-docs-contract`, `settings-jump-contract`
and `settings-buttons-contract` were not run against the two rebuilt pages.**
Six new components landed (LiveFieldRow, SecretFieldRow, PasswordStrengthRow,
StockAvatarPicker, FingerprintEnrollPanel, OnlineAccountRow) and are
registered in `qmldir`, but nothing here has loaded the QML.
- **The enrollment cancel path is pinned only from the source.** The canned
fprintd has no main loop, so `SIGTERM` releasing the device is exercised by
neither half; what is pinned is that the release happens in a `finally` and
that a *failed* enrollment still releases.
- **`user-accounts-contract`'s first half reads the live account service**
(`panama-users snapshot`, `stock-avatars`), so it wants the same quiet moment
the other system contracts do, though nothing it does writes.
- **Seams pinned by name**: `PANAMA_FINGERPRINT_FIXTURE`/`_LOG`,
`PANAMA_ACCOUNTS_FIXTURE`/`_LOG`, and the log shapes
(`{method, arguments}` and `{verb, arguments, passwordBytes}`). Changing
either is meant to be a deliberate act that updates these contracts.
- Run order for this phase: the hermetic ones first (`fingerprint-contract`,
`online-accounts-contract`), then the source-only ones
(`gnome-handoff-contract`, `setup/readme-contract`,
`settings-ownership-contract`, `search-routing-contract`), then the read-only
system one (`user-accounts-contract`), then the harness ones
(`settings-search-contract`), and `settings-pages-contract` last, as before.