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.
@@ -0,0 +1,171 @@
# Users & Accounts redesign — identity, honestly
Approved mock: `home-mocks/users.html` (scratchpad, :8642). Spec wins over mock on conflict.
## Goals
1. **Users**: profile hero (stock avatar gallery + file + remove), password change with strength
meter, live-validating add-user form, keep-files deletion choice, per-user management
(type / reset password / unlock), auto-login with the tradeoff stated.
2. **Fingerprint**: native guided enrollment (fprintd D-Bus), enrolled-finger list + removal,
and the stuck state made reachable — the card renders when the authselect feature is on
even with no reader, offering Turn off.
3. **Online Accounts**: "This desktop" cards (Home Assistant, phone) leading; GOA account cards
with confirmed removal and busy states; native add for password providers (Nextcloud, IMAP);
OAuth remains the single honest handoff. Page-level availability stops being gated on
transient write errors.
Non-goals: camera avatar capture, OAuth without GOA's dialog (impossible — no GIR), parental
controls, group management beyond account type, username changes.
## Helper extensions (pinned — as built)
**`scripts/panama-users`** (Python, unchanged language). Verbs:
`snapshot` · `stock-avatars` · `set-real-name USER NAME` · `set-icon USER PATH [X Y SIZE]` ·
`set-account-type USER standard|administrator` · `set-automatic-login USER true|false` ·
`set-locked USER true|false` · `set-password USER` (stdin) · `reset-password USER` ·
`create-user USERNAME REALNAME TYPE` · `delete-user USERNAME keep|remove`.
- `set-icon USER ""` clears the avatar (`SetIconFile("")`) — no separate verb.
- `stock-avatars` prints its own shape: `{"avatars":[{"name","path"}],"error":""}`. Absolute
paths, sorted by name; a missing `/usr/share/pixmaps/faces` yields `[]`. Fedora ships 15.
- `delete-user` takes `keep|remove`; the older `keep-files|remove-files` spelling still works.
- `reset-password USER``SetPasswordMode(1)`. No password material anywhere in the path.
- `set-account-type` now carries the last-admin refusal **in the helper** (same sentence as
`delete-user`), so a page that forgets the guard cannot demote the only administrator.
- Every mutating verb still answers with the fresh snapshot plus `error`, exit 0.
**`scripts/panama-fingerprint`** — **converted bash → Python** (a D-Bus signal loop is not a
thing bash can do). `status` and `set-unlock` keep their exact CLI and JSON, and still shell
out to `fprintd-list` / `authselect` / `panama-sudo --reason`, so the existing PATH-stub seam
is untouched. Verbs: `status` · `set-unlock on|off` · `enroll FINGER` · `remove FINGER` ·
`remove-all`.
- `status``{reader, readerName, enrolled, pamEnabled, unlockFeatureEnabled, error}`.
`pamEnabled` and `unlockFeatureEnabled` are the same fact under the old and new name.
**Behaviour change for C**: authselect is now parsed unconditionally, so a readerless
machine reports `pamEnabled: true` when the feature is on. That used to be forced to
`false`, which is what made the stuck state unreachable. (This machine is in it.)
- `enroll FINGER` streams one JSON object per line:
`{ok, stage, done, total, result, error}` with `stage ∈ claiming|scanning|done|failed`.
fprintd `Claim``EnrollStart` → one line per `EnrollStatus``EnrollStop``Release`.
Cancellation is SIGTERM (stopping the Process); the helper still releases the device.
Exit 0 on `enroll-completed`, 1 otherwise. 90s idle watchdog.
- `remove FINGER` / `remove-all``DeleteEnrolledFinger` / `DeleteEnrolledFingers2` under a
Claim, and answer with the **`status` shape** so the service has one absorb path.
- Finger vocabulary lives in `services/Fingerprint.qml` (`allFingers`, `fingerLabel`); the
helper only shape-checks the name.
- Fixture seam: `PANAMA_FINGERPRINT_FIXTURE` = JSON file
`{"enrollStages":N,"results":["enroll-stage-passed",…],"error":""}`; every D-Bus call it
stands in for is appended to `PANAMA_FINGERPRINT_LOG` (default `<fixture>.log`) as
`{"method","arguments"}`. No bus, no gi, no reader.
**`scripts/panama-accounts`**: `list` **and `snapshot`** (alias; the service asks for
`snapshot`) · `set` · `remove` · `add-nextcloud SERVER USERNAME` ·
`add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME`. Passwords on stdin, never argv. Both adds
answer with `{"accounts":[…],"error":""}`.
- **GOA's add flow, verified**: `Goa.Manager.AddAccount(provider, identity,
presentation_identity, credentials a{sv}, details a{ss}) → o` is fully daemon-side —
goa-daemon writes `accounts.conf` and calls `goa_utils_store_credentials_for_id_sync`
itself. The missing libgoa-backend GIR blocks only the *client-side sign-in dialog*, so
password providers are genuinely addable and OAuth genuinely is not. `IsSupportedProvider`
is checked first (`owncloud`, `imap_smtp` both true here; `nextcloud` is not a provider
type — `owncloud` is).
- Keys are GOA 3.58's own, taken from `goaowncloudprovider.c` / `goaimapsmtpprovider.c` and
confirmed against this machine's `accounts.conf`. Nextcloud: credentials `{password}`,
details `Uri` (`…/remote.php/webdav/`), `FilesEnabled`, `CalendarEnabled`, `CalDavUri`
(`…/remote.php/dav/`), `ContactsEnabled`, `CardDavUri`, `AcceptSslErrors`. IMAP:
credentials `{imap-password, smtp-password}`, details `Enabled`, `EmailAddress`, `Name`,
`Imap{Host,UserName,UseSsl,UseTls,AcceptSslErrors}`,
`Smtp{Host,UseAuth,UserName,AuthLogin,AuthPlain,UseSsl,UseTls,AcceptSslErrors}` — IMAPS +
submission/STARTTLS defaults, one password for both servers.
- The daemon validates nothing, so each add calls `EnsureCredentials` on the new object and
**removes the account again** if the credentials are refused, reporting why.
- Fixture seam: `PANAMA_ACCOUNTS_FIXTURE` = a JSON file in `list`'s shape. With it set nothing
imports gi; validation still runs; each verb appends `{"verb","arguments","passwordBytes"}`
to `PANAMA_ACCOUNTS_LOG` (default `<fixture>.log`) — the password's **length**, never itself.
## Services (A — as built)
- **UserAccounts**: `removeIcon()` (no argument — the hero card's own avatar),
`stockAvatars` + `loadStockAvatars()` (cached; call it when the gallery opens),
`deleteUser(user, keepFiles)` — **the bool inverted from the old `removeFiles`** —
`resetPassword(user)`, `setAccountTypeFor(user, type)` (**renamed** from `setAccountType`),
`setLocked(user, locked)`. `locked` and `loginTime` stay in the payload for the UI.
- **Fingerprint**: `readerPresent`, `readerName`, `unlockFeatureEnabled`, `fingers`
(**renamed** from `enrolled`), `allFingers`, `availableFingers`, `fingerLabel(f)`,
`cardVisible` (= `readerPresent || unlockFeatureEnabled`), `busy`, `lastError`,
`enrolling`, `enrollPhase`, `enrollFinger`, `enrollStage`, `enrollTotal`, `enrollResult`,
`refresh()`, `setUnlockEnabled(on)`, `startEnroll(finger)`, `cancelEnroll()`,
`removeFinger(f)`, `removeAll()`.
- **OnlineAccounts**: `available` (GOA answered the last snapshot — persisted, never touched
by a write), `snapshotError` / `writeError` and the derived `lastError`, `busy` (derived
from the Processes), `attentionCount`, `refresh()` (cheap; call on every page open),
`setService`, `remove`, `addNextcloud(server, user, password)`,
`addImap(email, imapHost, smtpHost, user, password)` — password via Process stdin.
## UI (B)
**UsersPage.qml** rebuilt per mock: hero card (avatar, Change picture… opens a popover with
stock gallery + "Choose a file…" → existing AvatarPicker/Cropper flow, Remove button);
Account card (Full name — live-committing field, Username honest row, Account type segment
with the last-admin reason as detail, Password flow with a 4-segment strength meter — local
heuristic, length + classes, no network; Automatic login with the mock's tradeoff copy);
Fingerprint card per mock (stuck row with NO READER badge + Turn off when
`unlockFeatureEnabled && !readerPresent`; full experience otherwise: toggle, enrolled list
with per-finger Remove…, Add a fingerprint → inline enrollment panel driven by the enroll
stream — touch icon, stage counter "N of M touches", cancel; no continuous animation);
Other accounts card (expandable per-user rows: type segment, Reset password ("They set a new
one at next sign-in"), Delete with a keep-files dropdown [Keep the files / Remove everything]
+ two-stage confirm; locked users show Unlock; add-user form with LIVE validation — use live
TextFields, not blur-committing TextFieldRow, with the username rules + 31-char cap in the
detail and mirrored in the enabled predicate).
**OnlineAccountsPage.qml** rebuilt: gains `objectName: "accounts"`; "This desktop" card —
Home Assistant row (state from the HomeAssistant service: connected/rooms, else "not set up",
button → `openSettings("my-home")`) and Phone row (KdeConnect state, → `openSettings("phone")`);
Accounts card — one expandable row per GOA account (per-service toggles disabled while
`busy`, Remove with two-stage confirm), needs-attention rows keep "Sign in again"; Add card —
Nextcloud + Mail native forms (server/user/password, password field cleared on success or
close), and the OAuth row with the mock's honest copy → the existing GNOME dialog handoff
(the ALLOWED exception stays for accounts). Errors render as a row, never as page-wide
unavailability.
## Search & docs (C)
New entries: Fingerprint, Enroll a fingerprint, Delete a user, Account type, Reset a password
→ users; Nextcloud, Google account, Remove an account, Add a mail account → accounts. Docs
regen only if schema changes (none expected — verify).
## Contracts (C — write; hermetic stub runs only)
- `user-accounts-contract`: extend — keep-files arg end to end, reset-password sends no
password material (SetPasswordMode pinned), remove-icon empty-string call, stock list shape,
live-validation predicate present in the page (the blur-commit bug regression pin: the add
form's fields must not be TextFieldRow).
- `fingerprint-contract`: major rework — enrollment is native now (streamed stages against a
stubbed fprintd D-Bus layer or a stub helper — follow the hermetic pattern), the GNOME
enrollment handoff needle INVERTS (page must NOT call openGnomePanel for users), stuck-state
visibility pinned (`readerPresent || unlockFeatureEnabled`), finger vocabulary single-source
kept, set-unlock reason kept.
- `gnome-handoff-contract`: delete the `UsersPage.qml:system-users` ALLOWED exception; the
`OnlineAccountsPage.qml:online-accounts` exception stays with its reason updated to name
OAuth specifically.
- NEW `online-accounts-contract`: hermetic — availability decoupled from write errors,
passwords via stdin never argv, add flows validate inputs, remove requires the confirm
state, snapshot exposes no secrets.
- Backlog Phase 11; README count line (171 → 172 expected).
## Agent ownership (parallel)
- **A**: `scripts/panama-users`, `scripts/panama-fingerprint`, `scripts/panama-accounts`,
`services/UserAccounts.qml`, `services/Fingerprint.qml`, `services/OnlineAccounts.qml`.
- **B**: `modules/settings/UsersPage.qml`, `OnlineAccountsPage.qml`, new components
(+ qmldir); may read AvatarPicker/AvatarCropper and reuse unchanged.
- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line.
Hard rules: NO live mutations — no user/password/icon/type changes, no GOA account writes,
no authselect, no fprintd enrollment against real hardware (none exists here anyway), no
polkit-triggering calls. Read-only probes and stubs only. Valid QML/Python at every save.
B programs against the pinned APIs; A updates this spec before changing them.