Closes #215. The Account page listed and added passkeys and could do nothing else: a lost or shared device, or a label typo, needed an instance admin and the CLI. Both are the person’s own credential, so both belong on the page.
What changed
- Rename.
identity.RenameCredentialwrites the label in one transaction and auditsidentity.credential-renamewith the passkey and its new label. A label is display text, so the credential id and key are untouched, and the cap is the one the ceremony already uses (MaxCredentialLabel). An empty label is allowed and the page calls it a passkey. - Remove.
identity.RemoveCredentialnow deletes that passkey’s sessions in the same transaction. The foreign key cascades already; saying it out loud makes it the intent rather than a schema accident, and it means the session the person is reading the page from ends with the passkey that signed it in. - The last passkey is refused, with an explanation, while the account has no SSH key either. With an SSH key it is a way in for Git, so an account that has one loses only its browser sign-in. With neither, the only way back is an admin invite, and losing that should never be a click that silently succeeded. Two passkeys are enough to rotate, so the rule protects the last one rather than every one.
- The page gets a rename and a remove form per passkey, each carrying the CSRF token, the removal with a confirmation, plus a line saying what removing one does. Handlers are scoped by the session account, so a form cannot name somebody else’s credential, and an unknown one is a 404 rather than a silent no-op.
Decisions
- Refuse the last passkey rather than allow and warn. The issue left both open. The cost of refusing is one extra click (register a replacement, then remove the old one); the cost of allowing is an account nobody can reach without an operator, which is exactly the silent lockout the issue asks us to avoid.
- An SSH key counts as a way in even though it cannot sign in to the browser: the forge’s job is Git, and an account with a key is still usable without an admin.
- Two audit records per write, as the SSH key forms already do: the identity store
records the change to the roster (
identity.credential-*), and the web surface records the action that made it (web.passkey.rename,web.passkey.remove).
Testing
- Identity: the label changes and the credential does not; whitespace is trimmed; an empty label clears it; an over-long one is refused where it is accepted; another account’s passkey is not renameable; a removed passkey takes its sessions with it.
- Server, end to end over the account page: rename one of two (the other keeps its label), remove one of several, a post without the token is refused, and the last passkey is refused with a message until an SSH key is added — after which the same removal succeeds.
vole workflow run verify passes (format, vet, build, licenses, govulncheck,
packages, deployment files, links, race suite). Docs: docs/accounts-and-access.md
covers renaming, the last-passkey rule, and the audit actions.