Styling and consistency tweaks, passkey names

This commit is contained in:
Jonathon Leight
2026-06-21 21:28:01 -04:00
parent bf0a907fcc
commit 0c453df563
12 changed files with 116 additions and 33 deletions
+20 -1
View File
@@ -13,6 +13,7 @@ import (
type passkeyView struct {
ID int64
ShortID string
Name string
Added time.Time
}
@@ -48,7 +49,7 @@ func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) {
if len(short) > 12 {
short = short[:12]
}
views = append(views, passkeyView{ID: c.ID, ShortID: short, Added: c.CreatedAt})
views = append(views, passkeyView{ID: c.ID, ShortID: short, Name: c.Name, Added: c.CreatedAt})
}
s.Render(w, r, "account.html", map[string]any{
"User": u,
@@ -122,6 +123,24 @@ func (s *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
accountRedirect(w, r, "ok", "Password updated.")
}
// handleRenamePasskey sets or clears the human-friendly label on one of the
// user's passkeys.
func (s *Handlers) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.Auth.CurrentUserID(ctx)
credID, err := strconv.ParseInt(r.FormValue("credential_id"), 10, 64)
if err != nil {
passkeyRedirect(w, r, "pkerr", "Invalid passkey.")
return
}
name := NormalizePasskeyName(r.FormValue("name"))
if err := s.Store.SetCredentialName(ctx, uid, credID, name); err != nil {
passkeyRedirect(w, r, "pkerr", "Could not rename that passkey.")
return
}
passkeyRedirect(w, r, "pk", "Passkey name saved.")
}
// handleDeletePasskey removes one of the user's passkeys, refusing to remove
// the last sign-in method.
func (s *Handlers) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
+19 -1
View File
@@ -53,12 +53,18 @@ func (s *Service) RegisterBegin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var u *store.User
var name string // optional label, only for adding a passkey to an existing account
if uid := s.CurrentUserID(ctx); uid != 0 {
var err error
if u, err = s.store.GetUserByID(ctx, uid); err != nil {
httpError(w, http.StatusInternalServerError, "load user")
return
}
var body struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
name = NormalizePasskeyName(body.Name)
} else {
username, displayName, ok := readCreds(r)
if !ok {
@@ -97,6 +103,7 @@ func (s *Service) RegisterBegin(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "save ceremony")
return
}
s.Sessions.Put(ctx, sessKeyWAName, name)
writeJSON(w, options)
}
@@ -129,7 +136,9 @@ func (s *Service) RegisterFinish(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "marshal credential")
return
}
if err := s.store.AddCredential(ctx, u.ID, cred.ID, blob); err != nil {
name := s.Sessions.GetString(ctx, sessKeyWAName)
s.Sessions.Remove(ctx, sessKeyWAName)
if err := s.store.AddCredential(ctx, u.ID, cred.ID, blob, name); err != nil {
httpError(w, http.StatusInternalServerError, "store credential")
return
}
@@ -347,6 +356,15 @@ func NormalizeDisplayName(s string) string {
return s
}
// NormalizePasskeyName trims and bounds a passkey label (empty means "unnamed").
func NormalizePasskeyName(s string) string {
s = strings.TrimSpace(s)
if len(s) > 64 {
s = s[:64]
}
return s
}
// ValidUsername reports whether s is 332 chars of [a-z0-9_.-].
func ValidUsername(s string) bool {
if len(s) < 3 || len(s) > 32 {
+1
View File
@@ -19,6 +19,7 @@ const (
sessKeyUserID = "user_id" // int64: the authenticated user
sessKeyWAUID = "wa_uid" // int64: user mid-ceremony
sessKeyWAData = "wa_data" // []byte: marshaled webauthn.SessionData
sessKeyWAName = "wa_name" // string: pending passkey name for the in-flight registration
sessKeyNext = "next" // string: post-auth redirect target
)
+17 -6
View File
@@ -55,12 +55,17 @@
{{if .Passkeys}}
<div class="list-group mb-3">
{{range .Passkeys}}
<div class="list-group-item d-flex align-items-center gap-2">
<div class="list-group-item d-flex align-items-center flex-wrap gap-2">
<span class="text-secondary">{{template "icon-key" ""}}</span>
<div class="flex-fill">
<div class="font-monospace">{{.ShortID}}</div>
<div class="text-secondary small">Added {{.Added.Format "Jan 2, 2006"}}</div>
<div class="flex-fill" style="min-width:10rem">
<div>{{if .Name}}{{.Name}}{{else}}<span class="text-secondary fst-italic">Unnamed passkey</span>{{end}}</div>
<div class="text-secondary small"><span class="font-monospace">{{.ShortID}}…</span> · Added {{.Added.Format "Jan 2, 2006"}}</div>
</div>
<form method="post" action="/account/passkeys/rename" class="d-flex gap-1 m-0">
<input type="hidden" name="credential_id" value="{{.ID}}">
<input type="text" class="form-control form-control-sm" name="name" maxlength="64" value="{{.Name}}" placeholder="Add a description" aria-label="Passkey description" style="width:11rem">
<button type="submit" class="btn btn-sm">Save</button>
</form>
<form method="post" action="/account/passkeys/delete" class="m-0" onsubmit="return confirm('Remove this passkey? You won\'t be able to sign in with it anymore.')">
<input type="hidden" name="credential_id" value="{{.ID}}">
<button type="submit" class="btn btn-sm btn-ghost-danger">Remove</button>
@@ -71,8 +76,14 @@
{{else}}
<p class="text-secondary">No passkeys yet. Add one for faster, more secure sign-in.</p>
{{end}}
<div>
<button type="button" class="btn btn-primary" onclick="addPasskey()">{{template "icon-key" "me-1"}}Add a passkey</button>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label" for="new_passkey_name">Description <span class="form-label-description">optional</span></label>
<input type="text" class="form-control" id="new_passkey_name" maxlength="64" placeholder="e.g. MacBook Touch ID" style="width:14rem">
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" onclick="addPasskey()">{{template "icon-key" "me-1"}}Add a passkey</button>
</div>
</div>
<p id="passkey-status" class="text-secondary small mt-2 mb-0" style="min-height:1.2em"></p>
+1
View File
@@ -52,6 +52,7 @@ func (s *Handlers) Routes() chi.Router {
r.Get("/account", s.pageAccount)
r.Post("/account/profile", s.handleUpdateProfile)
r.Post("/account/password", s.handleChangePassword)
r.Post("/account/passkeys/rename", s.handleRenamePasskey)
r.Post("/account/passkeys/delete", s.handleDeletePasskey)
})
+1 -1
View File
@@ -25,7 +25,7 @@
<button id="connect" class="btn btn-primary mt-3" type="button">{{template "icon-plug" "me-1"}}Connect modem &amp; confirm</button>
<ul id="log" class="evlog mt-3" role="log" aria-live="polite" aria-label="Confirmation activity"></ul>
<div class="mt-3">
<div class="mt-3 d-flex align-items-center">
<a class="back-link" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{if .Debug}}
<a class="text-secondary ms-2" href="/repeaters/{{.Repeater.PublicID}}/confirm">Disable debug</a>
+1 -1
View File
@@ -178,7 +178,7 @@
</div>
{{else}}
<div class="card-body">
<p class="text-secondary">You're not in any organizations yet. Join one to help tend a mesh together.</p>
<p class="text-secondary">You're not in any organizations yet. Find one near you and contribute to a larger mesh.</p>
<a href="{{.RootURL}}/orgs" class="btn btn-sm">Browse organizations</a>
</div>
{{end}}
+15 -10
View File
@@ -1,7 +1,10 @@
{{define "title"}}Your organizations · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Your organizations</h2></div>
<div class="col">
<div class="page-pretitle">Your network</div>
<h2 class="page-title">Organizations</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
<a class="btn" href="{{.RootURL}}/orgs">{{template "icon-world" "me-1"}}Discover organizations</a>
@@ -12,8 +15,8 @@
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .Orgs}}
<div class="card">
{{if .Orgs}}
<div class="list-group list-group-flush">
{{range .Orgs}}
<a class="list-group-item list-group-item-action" href="/orgs/{{.Org.Slug}}">
@@ -26,14 +29,16 @@
</a>
{{end}}
</div>
{{else}}
<div class="card-body text-center py-5">
<p class="text-secondary mb-3">You're not a member of any organizations yet.</p>
<div class="btn-list justify-content-center">
<a class="btn" href="{{.RootURL}}/orgs">{{template "icon-world" "me-1"}}Discover organizations</a>
<a class="btn btn-primary" href="/orgs/new">{{template "icon-plus" "me-1"}}Create one</a>
</div>
</div>
{{else}}
<div class="empty">
<div class="empty-icon">{{template "icon-world" ""}}</div>
<p class="empty-title">No organizations yet</p>
<p class="empty-subtitle text-secondary">You're not in any organizations yet. Find one near you and contribute to a larger mesh.</p>
<div class="empty-action">
<a class="btn btn-primary" href="{{.RootURL}}/orgs">{{template "icon-world" "me-1"}}Discover organizations</a>
<a class="btn" href="/orgs/new">{{template "icon-plus" "me-1"}}Create one</a>
</div>
{{end}}
</div>
{{end}}
{{end}}
@@ -0,0 +1,7 @@
-- +goose Up
-- Optional human-friendly label for a passkey, shown on the account page so a
-- user can tell their credentials apart (e.g. "MacBook Touch ID", "YubiKey").
ALTER TABLE webauthn_credentials ADD COLUMN name TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE webauthn_credentials DROP COLUMN name;
+23 -6
View File
@@ -180,11 +180,12 @@ func (s *Store) ClearPassword(ctx context.Context, userID int64) error {
return nil
}
// AddCredential stores a marshaled WebAuthn credential for a user.
func (s *Store) AddCredential(ctx context.Context, userID int64, credentialID []byte, data []byte) error {
// AddCredential stores a marshaled WebAuthn credential for a user, with an
// optional human-friendly name (empty means unnamed).
func (s *Store) AddCredential(ctx context.Context, userID int64, credentialID []byte, data []byte, name string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO webauthn_credentials (user_id, credential_id, data) VALUES ($1, $2, $3)`,
userID, credentialID, data)
`INSERT INTO webauthn_credentials (user_id, credential_id, data, name) VALUES ($1, $2, $3, $4)`,
userID, credentialID, data, name)
if isUniqueViolation(err) {
return ErrDuplicate
}
@@ -212,24 +213,40 @@ func (s *Store) GetCredentials(ctx context.Context, userID int64) ([][]byte, err
type CredentialInfo struct {
ID int64
CredentialID []byte
Name string
CreatedAt time.Time
}
// ListCredentials returns metadata for a user's passkeys, newest first.
func (s *Store) ListCredentials(ctx context.Context, userID int64) ([]CredentialInfo, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, credential_id, created_at FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC, id DESC`,
`SELECT id, credential_id, name, created_at FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC, id DESC`,
userID)
if err != nil {
return nil, fmt.Errorf("list credentials: %w", err)
}
return collectRows(rows, func(r pgx.Row) (CredentialInfo, error) {
var c CredentialInfo
err := r.Scan(&c.ID, &c.CredentialID, &c.CreatedAt)
err := r.Scan(&c.ID, &c.CredentialID, &c.Name, &c.CreatedAt)
return c, err
})
}
// SetCredentialName updates the human-friendly label on one of the user's
// passkeys. It scopes the update to the owner and returns ErrNotFound if no
// such credential exists.
func (s *Store) SetCredentialName(ctx context.Context, userID, credentialRowID int64, name string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE webauthn_credentials SET name = $1 WHERE id = $2 AND user_id = $3`, name, credentialRowID, userID)
if err != nil {
return fmt.Errorf("set credential name: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// CountCredentials returns how many passkeys a user has registered.
func (s *Store) CountCredentials(ctx context.Context, userID int64) (int, error) {
var n int
+8 -6
View File
@@ -82,17 +82,19 @@ code.pubkey {
border: 1px solid var(--tblr-border-color);
}
.leaflet-container { background: #14171c; }
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
/* Scoped under .leaflet-popup so these beat Leaflet's own defaults, which load
after app.css (leaflet.css is pulled in per-page in the map card body). */
.leaflet-popup .leaflet-popup-content-wrapper,
.leaflet-popup .leaflet-popup-tip {
background: var(--tblr-bg-surface, #1a1d24);
color: var(--tblr-body-color, #e6edf3);
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
}
.leaflet-popup-content { color: var(--tblr-body-color, #e6edf3); }
.leaflet-bar a,
.leaflet-control-zoom a {
.leaflet-popup .leaflet-popup-content { color: var(--tblr-body-color, #e6edf3); }
.leaflet-container .leaflet-bar a,
.leaflet-container .leaflet-control-zoom a {
background: var(--tblr-bg-surface, #1a1d24);
color: var(--tblr-body-color, #e6edf3);
border-bottom-color: var(--tblr-border-color, #2b2f36);
}
.leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary, #22262e); }
.leaflet-container .leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary, #22262e); }
+3 -1
View File
@@ -118,8 +118,10 @@ async function finishAssertion(url, cred) {
// (used from the account page) and reloads it on success.
async function addPasskey() {
try {
const nameEl = document.getElementById("new_passkey_name");
const name = nameEl ? nameEl.value.trim() : "";
setStatus("Starting…");
const options = await postJSON("/api/register/begin", {});
const options = await postJSON("/api/register/begin", { name });
const cred = await navigator.credentials.create({ publicKey: decodeCreation(options.publicKey) });
setStatus("Verifying…");
const result = await fetch("/api/register/finish", {