UI: pgcreds create button; show logged-in user

* web/templates/pgcreds.html: New Credentials card is now always
  rendered; Add Credentials toggle button reveals the create form
  (hidden by default). Shows a message when all system accounts
  already have credentials. Previously the card was hidden when
  UncredentialedAccounts was empty.
* internal/ui/ui.go: added ActorName string field to PageData;
  added actorName(r) helper resolving username from JWT claims
  via DB lookup, returns empty string if unauthenticated.
* internal/ui/handlers_*.go: all full-page PageData constructors
  now pass ActorName: u.actorName(r).
* web/templates/base.html: nav bar renders actor username as a
  muted label before the Logout button when logged in.
* web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
This commit is contained in:
2026-03-12 11:38:57 -07:00
parent bbf9f6fe3f
commit b2f2f04646
19 changed files with 1152 additions and 49 deletions

View File

@@ -141,6 +141,22 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
return false
},
"not": func(b bool) bool { return !b },
// derefInt64 safely dereferences a *int64, returning 0 for nil.
// Used in templates to compare owner IDs without triggering nil panics.
"derefInt64": func(p *int64) int64 {
if p == nil {
return 0
}
return *p
},
// isPGCredOwner returns true when actorID and cred are both non-nil
// and actorID matches cred.OwnerID. Safe to call with nil arguments.
"isPGCredOwner": func(actorID *int64, cred *model.PGCredential) bool {
if actorID == nil || cred == nil || cred.OwnerID == nil {
return false
}
return *actorID == *cred.OwnerID
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"gt": func(a, b int) bool { return a > b },
@@ -190,6 +206,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html",
}
tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles {
@@ -264,6 +281,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("DELETE /token/{jti}", admin(u.handleRevokeToken))
uiMux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken))
uiMux.Handle("PUT /accounts/{id}/pgcreds", admin(u.handleSetPGCreds))
uiMux.Handle("POST /accounts/{id}/pgcreds/access", admin(u.handleGrantPGCredAccess))
uiMux.Handle("DELETE /accounts/{id}/pgcreds/access/{grantee}", admin(u.handleRevokePGCredAccess))
uiMux.Handle("GET /pgcreds", adminGet(u.handlePGCredsList))
uiMux.Handle("POST /pgcreds", admin(u.handleCreatePGCreds))
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
@@ -478,6 +499,21 @@ func clientIP(r *http.Request) string {
return addr
}
// actorName resolves the username of the currently authenticated user from the
// request context. Returns an empty string if claims are absent or the account
// cannot be found; callers should treat an empty string as "not logged in".
func (u *UIServer) actorName(r *http.Request) string {
claims := claimsFromContext(r.Context())
if claims == nil {
return ""
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
return ""
}
return acct.Username
}
// ---- Page data types ----
// PageData is embedded in all page-level view structs.
@@ -485,6 +521,9 @@ type PageData struct {
CSRFToken string
Flash string
Error string
// ActorName is the username of the currently logged-in user, populated by
// handlers so the base template can display it in the navigation bar.
ActorName string
}
// LoginData is the view model for the login page.
@@ -514,7 +553,16 @@ type AccountsData struct {
// AccountDetailData is the view model for the account detail page.
type AccountDetailData struct {
Account *model.Account
PGCred *model.PGCredential // nil if none stored or account is not a system account
// PGCred is nil if none stored or the account is not a system account.
PGCred *model.PGCredential
// PGCredGrants lists accounts that have been granted read access to PGCred.
// Only populated when the viewing actor is the credential owner.
PGCredGrants []*model.PGCredAccessGrant
// GrantableAccounts is the list of accounts the owner may grant access to.
GrantableAccounts []*model.Account
// ActorID is the DB id of the currently logged-in user; used in templates
// to decide whether to show the owner-only management controls.
ActorID *int64
PageData
Roles []string
AllRoles []string
@@ -556,3 +604,21 @@ type PoliciesData struct {
Rules []*PolicyRuleView
AllActions []string
}
// PGCredsData is the view model for the "My PG Credentials" list page.
// It shows all pg_credentials sets accessible to the currently logged-in user:
// those they own and those they have been granted access to.
// UncredentialedAccounts is the list of system accounts that have no credentials
// yet, populated to drive the "New Credentials" create form on the same page.
// CredGrants maps credential ID to the list of access grants for that credential;
// only populated for credentials the actor owns.
// AllAccounts is used to populate the grant-access dropdown for owned credentials.
// ActorID is the DB id of the currently logged-in user.
type PGCredsData struct {
CredGrants map[int64][]*model.PGCredAccessGrant
ActorID *int64
PageData
Creds []*model.PGCredential
UncredentialedAccounts []*model.Account
AllAccounts []*model.Account
}