mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
Styling and consistency tweaks, passkey names
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<button id="connect" class="btn btn-primary mt-3" type="button">{{template "icon-plug" "me-1"}}Connect modem & 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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user