vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Let people rename and remove their own passkeys
7d7718fac3edfc9b8e27d1e55721a6f9ca9106af · Keenan Nemetz · 7h ago · parent 5f6163bb
Let people rename and remove their own passkeys 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. - Rename: `identity.RenameCredential` writes the label in one transaction and audits `identity.credential-rename` with 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.RemoveCredential` now deletes the sessions of that passkey in the same transaction. The foreign key cascades already, and saying it out loud is what makes it the intent rather than a schema accident: the session the person is reading the page from ends with the passkey it signed in with. - The last passkey is refused, with an explanation, while the account has no SSH key either. With one 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: the last one is protected, not every one. - The page gets a rename form and a remove form per passkey, each with the CSRF token and a confirmation on the removal, and a line saying what removing one does. The handlers are scoped by the session account, so the form cannot name somebody else's credential, and an unknown one is a 404 rather than a silent no-op. Docs: `docs/accounts-and-access.md` covers renaming, the last-passkey rule, and the audit actions for both. Tests: the label changes and the credential does not, an empty label clears it, an over-long one is refused where it is accepted, and another account's passkey is not renameable; a removed passkey takes its sessions with it (identity); and end to end over the account page — rename one of two, remove one of several, a post without the token is refused, and the last passkey is refused until an SSH key is added. Closes 01M6SASJYFVH6Z3P6BXTEMA83
server/web/templates/account.html +17−2
| @@ -80,19 +80,34 @@ | |||
| 80 | 80 | {{if .Credentials}} | |
| 81 | 81 | <div class="panel"> | |
| 82 | 82 | <table> | |
| 83 | <thead><tr><th>Credential</th><th>Label</th><th>Created</th><th>Last used</th></tr></thead> | ||
| 83 | <thead><tr><th>Credential</th><th>Label</th><th>Created</th><th>Last used</th><th></th></tr></thead> | ||
| 84 | 84 | <tbody> | |
| 85 | 85 | {{range .Credentials}} | |
| 86 | 86 | <tr> | |
| 87 | 87 | <td class="sha">{{credid .ID}}</td> | |
| 88 | <td>{{if .Label}}{{.Label}}{{else}}passkey{{end}}</td> | ||
| 88 | <td> | ||
| 89 | <form method="post" action="/-/account/passkeys/rename" class="inline"> | ||
| 90 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> | ||
| 91 | <input type="hidden" name="credential" value="{{credid .ID}}"> | ||
| 92 | <input type="text" name="label" value="{{.Label}}" maxlength="128" placeholder="passkey" aria-label="Passkey label"> | ||
| 93 | <button class="link" type="submit">rename</button> | ||
| 94 | </form> | ||
| 95 | </td> | ||
| 89 | 96 | <td class="muted">{{since .Created.Unix}}</td> | |
| 90 | 97 | <td class="muted">{{if .LastUsed.IsZero}}never{{else}}{{since .LastUsed.Unix}}{{end}}</td> | |
| 98 | <td> | ||
| 99 | <form method="post" action="/-/account/passkeys/remove" data-confirm="Remove this passkey? Sessions that signed in with it end now."> | ||
| 100 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> | ||
| 101 | <input type="hidden" name="credential" value="{{credid .ID}}"> | ||
| 102 | <button class="link" type="submit">remove</button> | ||
| 103 | </form> | ||
| 104 | </td> | ||
| 91 | 105 | </tr> | |
| 92 | 106 | {{end}} | |
| 93 | 107 | </tbody> | |
| 94 | 108 | </table> | |
| 95 | 109 | </div> | |
| 110 | <p class="muted">A label is only what you call a passkey; renaming one does not change what it can do. Removing one ends the sessions that signed in with it. Your last passkey cannot be removed while it is your only way into your account: register another passkey or add an SSH key first.</p> | ||
| 96 | 111 | {{else}} | |
| 97 | 112 | <div class="empty-state"><p>No passkeys yet.</p></div> | |
| 98 | 113 | {{end}} | |