From 0c453df5630cc809183b1ce25151bfefc084b073 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 21 Jun 2026 21:28:01 -0400 Subject: [PATCH] Styling and consistency tweaks, passkey names --- internal/auth/account.go | 21 +++++++++++++- internal/auth/handlers.go | 20 ++++++++++++- internal/auth/service.go | 1 + internal/auth/templates/account.html | 23 +++++++++++---- internal/auth/web.go | 1 + internal/core/templates/confirm.html | 2 +- internal/core/templates/dashboard.html | 2 +- internal/core/templates/my_orgs.html | 25 +++++++++------- .../store/migrations/0016_passkey_name.sql | 7 +++++ internal/store/users.go | 29 +++++++++++++++---- internal/web/static/app.css | 14 +++++---- internal/web/static/webauthn.js | 4 ++- 12 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 internal/store/migrations/0016_passkey_name.sql diff --git a/internal/auth/account.go b/internal/auth/account.go index 893b200..b0e34bd 100644 --- a/internal/auth/account.go +++ b/internal/auth/account.go @@ -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) { diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index e656b1a..fa2bd3b 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -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 3–32 chars of [a-z0-9_.-]. func ValidUsername(s string) bool { if len(s) < 3 || len(s) > 32 { diff --git a/internal/auth/service.go b/internal/auth/service.go index 6862959..ebbbf44 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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 ) diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html index d2c6510..a720785 100644 --- a/internal/auth/templates/account.html +++ b/internal/auth/templates/account.html @@ -55,12 +55,17 @@ {{if .Passkeys}}
{{range .Passkeys}} -
+
{{template "icon-key" ""}} -
-
{{.ShortID}}…
-
Added {{.Added.Format "Jan 2, 2006"}}
+
+
{{if .Name}}{{.Name}}{{else}}Unnamed passkey{{end}}
+
{{.ShortID}}… · Added {{.Added.Format "Jan 2, 2006"}}
+
+ + + +
@@ -71,8 +76,14 @@ {{else}}

No passkeys yet. Add one for faster, more secure sign-in.

{{end}} -
- +
+
+ + +
+
+ +

diff --git a/internal/auth/web.go b/internal/auth/web.go index dd04109..ee25a4e 100644 --- a/internal/auth/web.go +++ b/internal/auth/web.go @@ -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) }) diff --git a/internal/core/templates/confirm.html b/internal/core/templates/confirm.html index e555f91..94af626 100644 --- a/internal/core/templates/confirm.html +++ b/internal/core/templates/confirm.html @@ -25,7 +25,7 @@
    -
    +
    {{template "icon-arrow-left" "me-1"}}Back to dashboard {{if .Debug}} Disable debug diff --git a/internal/core/templates/dashboard.html b/internal/core/templates/dashboard.html index fda8ea4..b337008 100644 --- a/internal/core/templates/dashboard.html +++ b/internal/core/templates/dashboard.html @@ -178,7 +178,7 @@
    {{else}}
    -

    You're not in any organizations yet. Join one to help tend a mesh together.

    +

    You're not in any organizations yet. Find one near you and contribute to a larger mesh.

    Browse organizations
    {{end}} diff --git a/internal/core/templates/my_orgs.html b/internal/core/templates/my_orgs.html index 630c4ab..c9d2095 100644 --- a/internal/core/templates/my_orgs.html +++ b/internal/core/templates/my_orgs.html @@ -1,7 +1,10 @@ {{define "title"}}Your organizations · MeshTender{{end}} {{define "header"}}
    -

    Your organizations

    +
    +
    Your network
    +

    Organizations

    +
    {{template "icon-world" "me-1"}}Discover organizations @@ -12,8 +15,8 @@ {{end}} {{define "content"}} {{if .Error}}
    {{.Error}}
    {{end}} +{{if .Orgs}}
    - {{if .Orgs}}
    {{range .Orgs}} @@ -26,14 +29,16 @@ {{end}}
    - {{else}} - +{{else}} +
    +
    {{template "icon-world" ""}}
    +

    No organizations yet

    +

    You're not in any organizations yet. Find one near you and contribute to a larger mesh.

    + - {{end}}
    {{end}} +{{end}} diff --git a/internal/store/migrations/0016_passkey_name.sql b/internal/store/migrations/0016_passkey_name.sql new file mode 100644 index 0000000..51c16f8 --- /dev/null +++ b/internal/store/migrations/0016_passkey_name.sql @@ -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; diff --git a/internal/store/users.go b/internal/store/users.go index ce0c63f..00603b7 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5aa6412..5ad1d08 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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); } diff --git a/internal/web/static/webauthn.js b/internal/web/static/webauthn.js index 982d765..67e9e65 100644 --- a/internal/web/static/webauthn.js +++ b/internal/web/static/webauthn.js @@ -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", {