Fix UI: install real HTMX, add PG creds and roles UI

- web/static/htmx.min.js: replace placeholder stub with
  htmx 2.0.4 (downloaded from unpkg.com). The placeholder
  only logged a console warning; no HTMX features worked,
  so form submissions fell back to native POSTs and the
  account_row fragment was returned as a raw HTML body
  rather than spliced into the table. This was the root
  cause of account creation appearing to 'do nothing'.
- internal/ui/ui.go: add pgcreds_form.html to shared
  template list; add PUT /accounts/{id}/pgcreds route;
  reorder AccountDetailData fields so embedded PageData
  does not shadow Account.
- internal/ui/handlers_accounts.go: add handleSetPGCreds
  handler — encrypts the submitted password with AES-256-GCM
  using the server master key before storage, validates
  system-account-only constraint, re-reads and re-renders
  the fragment after save. Add PGCred field population to
  handleAccountDetail.
- internal/ui/ui_test.go: add tests for account creation,
  role management, and PG credential handlers.
- web/templates/account_detail.html: add Postgres
  Credentials card for system accounts.
- web/templates/fragments/pgcreds_form.html: new fragment
  for the PG credentials form; CSRF token is supplied via
  the body-level hx-headers attribute in base.html.
Security: PG password is encrypted with AES-256-GCM
(crypto.SealAESGCM) before storage; a fresh nonce is
generated per call; the plaintext is never logged or
returned in responses.
This commit is contained in:
2026-03-11 22:30:13 -07:00
parent 9b0adfdde4
commit 5a8698e199
7 changed files with 528 additions and 14 deletions

View File

@@ -15,8 +15,10 @@
//
// Commands:
//
// auth login -username NAME [-password PASS] [-totp CODE]
//
// account list
// account create -username NAME -password PASS [-type human|system]
// account create -username NAME [-password PASS] [-type human|system]
// account get -id UUID
// account update -id UUID [-status active|inactive]
// account delete -id UUID
@@ -27,7 +29,7 @@
// token issue -id UUID
// token revoke -jti JTI
//
// pgcreds set -id UUID -host HOST -port PORT -db DB -user USER -password PASS
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
// pgcreds get -id UUID
package main
@@ -41,6 +43,8 @@ import (
"os"
"strings"
"time"
"golang.org/x/term"
)
func main() {
@@ -79,6 +83,8 @@ func main() {
subArgs := args[1:]
switch command {
case "auth":
ctl.runAuth(subArgs)
case "account":
ctl.runAccount(subArgs)
case "role":
@@ -99,6 +105,78 @@ type controller struct {
token string
}
// ---- auth subcommands ----
func (c *controller) runAuth(args []string) {
if len(args) == 0 {
fatalf("auth requires a subcommand: login")
}
switch args[0] {
case "login":
c.authLogin(args[1:])
default:
fatalf("unknown auth subcommand %q", args[0])
}
}
// authLogin authenticates with the server using username and password, then
// prints the resulting bearer token to stdout. If -password is not supplied on
// the command line, the user is prompted interactively (input is hidden so the
// password does not appear in shell history or terminal output).
//
// Security: passwords are never stored by this process beyond the lifetime of
// the HTTP request. Interactive reads use golang.org/x/term.ReadPassword so
// that terminal echo is disabled; the byte slice is zeroed after use.
func (c *controller) authLogin(args []string) {
fs := flag.NewFlagSet("auth login", flag.ExitOnError)
username := fs.String("username", "", "username (required)")
password := fs.String("password", "", "password (reads from stdin if omitted)")
totpCode := fs.String("totp", "", "TOTP code (required if TOTP is enrolled)")
_ = fs.Parse(args)
if *username == "" {
fatalf("auth login: -username is required")
}
// If no password flag was provided, prompt interactively so it does not
// appear in process arguments or shell history.
passwd := *password
if passwd == "" {
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
fatalf("read password: %v", err)
}
passwd = string(raw)
// Zero the raw byte slice once copied into the string.
for i := range raw {
raw[i] = 0
}
}
body := map[string]string{
"username": *username,
"password": passwd,
}
if *totpCode != "" {
body["totp_code"] = *totpCode
}
var result struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
c.doRequest("POST", "/v1/auth/login", body, &result)
// Print token to stdout so it can be captured by scripts, e.g.:
// export MCIAS_TOKEN=$(mciasctl auth login -username alice)
fmt.Println(result.Token)
if result.ExpiresAt != "" {
fmt.Fprintf(os.Stderr, "expires: %s\n", result.ExpiresAt)
}
}
// ---- account subcommands ----
func (c *controller) runAccount(args []string) {
@@ -130,7 +208,7 @@ func (c *controller) accountList() {
func (c *controller) accountCreate(args []string) {
fs := flag.NewFlagSet("account create", flag.ExitOnError)
username := fs.String("username", "", "username (required)")
password := fs.String("password", "", "password (required for human accounts)")
password := fs.String("password", "", "password for human accounts (prompted if omitted)")
accountType := fs.String("type", "human", "account type: human or system")
_ = fs.Parse(args)
@@ -138,12 +216,30 @@ func (c *controller) accountCreate(args []string) {
fatalf("account create: -username is required")
}
// For human accounts, prompt for a password interactively if one was not
// supplied on the command line so it stays out of shell history.
// Security: terminal echo is disabled during entry; the raw byte slice is
// zeroed after conversion to string. System accounts have no password.
passwd := *password
if passwd == "" && *accountType == "human" {
fmt.Fprint(os.Stderr, "Password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd = string(raw)
for i := range raw {
raw[i] = 0
}
}
body := map[string]string{
"username": *username,
"account_type": *accountType,
}
if *password != "" {
body["password"] = *password
if passwd != "" {
body["password"] = passwd
}
var result json.RawMessage
@@ -332,11 +428,29 @@ func (c *controller) pgCredsSet(args []string) {
port := fs.Int("port", 5432, "Postgres port")
dbName := fs.String("db", "", "Postgres database name (required)")
username := fs.String("user", "", "Postgres username (required)")
password := fs.String("password", "", "Postgres password (required)")
password := fs.String("password", "", "Postgres password (prompted if omitted)")
_ = fs.Parse(args)
if *id == "" || *host == "" || *dbName == "" || *username == "" || *password == "" {
fatalf("pgcreds set: -id, -host, -db, -user, and -password are required")
if *id == "" || *host == "" || *dbName == "" || *username == "" {
fatalf("pgcreds set: -id, -host, -db, and -user are required")
}
// Prompt for the Postgres password interactively if not supplied so it
// stays out of shell history.
// Security: terminal echo is disabled during entry; the raw byte slice is
// zeroed after conversion to string.
passwd := *password
if passwd == "" {
fmt.Fprint(os.Stderr, "Postgres password: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
fatalf("read password: %v", err)
}
passwd = string(raw)
for i := range raw {
raw[i] = 0
}
}
body := map[string]interface{}{
@@ -344,7 +458,7 @@ func (c *controller) pgCredsSet(args []string) {
"port": *port,
"database": *dbName,
"username": *username,
"password": *password,
"password": passwd,
}
c.doRequest("PUT", "/v1/accounts/"+*id+"/pgcreds", body, nil)
fmt.Println("credentials stored")
@@ -455,8 +569,13 @@ Global flags:
-cacert Path to CA certificate for TLS verification
Commands:
auth login -username NAME [-password PASS] [-totp CODE]
Obtain a bearer token. Password is prompted if -password is
omitted. Token is written to stdout; expiry to stderr.
Example: export MCIAS_TOKEN=$(mciasctl auth login -username alice)
account list
account create -username NAME -password PASS [-type human|system]
account create -username NAME [-password PASS] [-type human|system]
account get -id UUID
account update -id UUID -status active|inactive
account delete -id UUID
@@ -468,6 +587,6 @@ Commands:
token revoke -jti JTI
pgcreds get -id UUID
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER -password PASS
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
`)
}

View File

@@ -1,11 +1,15 @@
package ui
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate"
)
@@ -128,12 +132,24 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
tokens = nil
}
// Load PG credentials for system accounts only; leave nil for human accounts
// and when no credentials have been stored yet.
var pgCred *model.PGCredential
if acct.AccountType == model.AccountTypeSystem {
pgCred, err = u.db.ReadPGCredentials(acct.ID)
if err != nil && !errors.Is(err, db.ErrNotFound) {
u.logger.Warn("read pg credentials", "error", err)
}
// ErrNotFound is expected when no credentials have been stored yet.
}
u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Roles: roles,
AllRoles: knownRoles,
Tokens: tokens,
PGCred: pgCred,
})
}
@@ -337,6 +353,114 @@ func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// handleSetPGCreds stores (or replaces) encrypted Postgres credentials for a
// system account. The submitted password is encrypted with AES-256-GCM using the
// server master key before storage and is never echoed back in the response.
//
// Security: Only system accounts may hold PG credentials. The password field is
// write-only — the UI displays only connection metadata (host, port, database,
// username) after save. Audit event EventPGCredUpdated is recorded on success.
func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
// Security: PG credentials are only meaningful for system accounts.
if acct.AccountType != model.AccountTypeSystem {
u.renderError(w, r, http.StatusBadRequest, "postgres credentials are only available for system accounts")
return
}
host := strings.TrimSpace(r.FormValue("host"))
portStr := strings.TrimSpace(r.FormValue("port"))
dbName := strings.TrimSpace(r.FormValue("database"))
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if host == "" {
u.renderError(w, r, http.StatusBadRequest, "host is required")
return
}
if dbName == "" {
u.renderError(w, r, http.StatusBadRequest, "database is required")
return
}
if username == "" {
u.renderError(w, r, http.StatusBadRequest, "username is required")
return
}
// Security: password is required on every write — the UI does not carry the
// existing password, so callers must supply it explicitly.
if password == "" {
u.renderError(w, r, http.StatusBadRequest, "password is required")
return
}
port := 5432
if portStr != "" {
port, err = strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
u.renderError(w, r, http.StatusBadRequest, "port must be an integer between 1 and 65535")
return
}
}
// Security: encrypt the password with AES-256-GCM before storage.
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
// is not possible. The plaintext password is not retained after this call.
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
if err != nil {
u.logger.Error("encrypt pg password", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.WritePGCredentials(acct.ID, host, port, dbName, username, enc, nonce); err != nil {
u.logger.Error("write pg credentials", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to save credentials")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
actor, err := u.db.GetAccountByUUID(claims.Subject)
if err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPGCredUpdated, actorID, &acct.ID, "")
// Re-read the stored record to populate the metadata display.
// The encrypted blobs are loaded but the password field (PGPassword) remains
// empty — it is only decrypted on explicit admin retrieval via gRPC.
pgCred, err := u.db.ReadPGCredentials(acct.ID)
if err != nil {
u.logger.Warn("re-read pg credentials after write", "error", err)
pgCred = nil
}
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
csrfToken = ""
}
u.render(w, "pgcreds_form", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
PGCred: pgCred,
})
}
// handleIssueSystemToken issues a long-lived service token for a system account.
func (u *UIServer) handleIssueSystemToken(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")

View File

@@ -170,6 +170,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/totp_step.html",
"templates/fragments/error.html",
"templates/fragments/audit_rows.html",
"templates/fragments/pgcreds_form.html",
}
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil {
@@ -258,6 +259,7 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PUT /accounts/{id}/roles", admin(u.handleSetRoles))
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("GET /audit", adminGet(u.handleAuditPage))
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
@@ -502,8 +504,9 @@ 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
PageData
Account *model.Account
Roles []string
AllRoles []string
Tokens []*model.TokenRecord

View File

@@ -3,10 +3,12 @@ package ui
import (
"crypto/ed25519"
"crypto/rand"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -14,6 +16,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
)
const testIssuer = "https://auth.example.com"
@@ -299,3 +302,228 @@ func TestLoginPostPasswordNotInTOTPForm(t *testing.T) {
t.Error("two consecutive nonces are identical (randomness failure)")
}
}
// ---- PG credentials UI tests ----
// issueAdminSession creates a system account with the "admin" role, issues a
// JWT for it, tracks the token in the DB, and returns the raw token string
// along with the account UUID for constructing request paths.
func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string, accountID int64) {
t.Helper()
acct, err := u.db.CreateAccount("pgtest-admin", model.AccountTypeHuman, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
t.Fatalf("SetRoles: %v", err)
}
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
return tok, acct.UUID, acct.ID
}
// authenticatedPUT builds a PUT request to path with the session cookie, a
// valid CSRF cookie + header, and the given form body.
func authenticatedPUT(t *testing.T, u *UIServer, path string, sessionToken string, form url.Values) *http.Request {
t.Helper()
body := strings.NewReader(form.Encode())
req := httptest.NewRequest(http.MethodPut, path, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
csrfCookieVal, csrfHeaderVal, err := u.csrf.NewToken()
if err != nil {
t.Fatalf("csrf.NewToken: %v", err)
}
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfCookieVal})
req.Header.Set("X-CSRF-Token", csrfHeaderVal)
return req
}
// authenticatedGET builds a GET request with the session cookie set.
func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
return req
}
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
// endpoint returns 400 when the target account is a human (not system) account.
func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
// Create a human account to target.
humanAcct, err := u.db.CreateAccount("human-target", model.AccountTypeHuman, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
form := url.Values{
"host": {"db.example.com"}, "database": {"mydb"},
"username": {"user"}, "password": {"s3cret"},
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", humanAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
}
// TestSetPGCredsStoresAndDisplaysMetadata verifies that a valid PUT on a system
// account stores the credentials and returns host, database, and username in the
// response body (non-sensitive metadata). The password must not appear.
func TestSetPGCredsStoresAndDisplaysMetadata(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-target", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
const (
testHost = "pghost.internal"
testDB = "appdb"
testUsername = "appuser"
testPassword = "super-secret-pw"
)
form := url.Values{
"host": {testHost}, "port": {"5433"}, "database": {testDB},
"username": {testUsername}, "password": {testPassword},
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{testHost, testDB, testUsername} {
if !strings.Contains(body, want) {
t.Errorf("response body missing %q; got:\n%s", want, body)
}
}
// Security: password must not appear anywhere in the response.
if strings.Contains(body, testPassword) {
t.Errorf("response body contains plaintext password — security violation")
}
}
// TestSetPGCredsPasswordNotEchoed explicitly confirms the plaintext password is
// absent from the response even when all other fields are valid.
func TestSetPGCredsPasswordNotEchoed(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-echo-check", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
const secretPassword = "must-not-appear-in-response"
form := url.Values{
"host": {"h"}, "database": {"d"}, "username": {"u"}, "password": {secretPassword},
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if strings.Contains(rr.Body.String(), secretPassword) {
t.Error("response body contains the plaintext password — security violation")
}
}
// TestSetPGCredsRequiresPassword verifies that omitting the password returns an error.
func TestSetPGCredsRequiresPassword(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-no-pw", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
form := url.Values{
"host": {"h"}, "database": {"d"}, "username": {"u"}, // password intentionally absent
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d (password required)", rr.Code, http.StatusBadRequest)
}
}
// TestAccountDetailShowsPGCredsSection verifies that the account detail page for
// a system account includes the pgcreds section, and that a human account does not.
func TestAccountDetailShowsPGCredsSection(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-detail", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount (system): %v", err)
}
humanAcct, err := u.db.CreateAccount("human-detail", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount (human): %v", err)
}
// System account detail must include the pgcreds section.
sysReq := authenticatedGET(t, sessionToken, fmt.Sprintf("/accounts/%s", sysAcct.UUID))
sysRR := httptest.NewRecorder()
mux.ServeHTTP(sysRR, sysReq)
if sysRR.Code != http.StatusOK {
t.Fatalf("system account GET status = %d, want 200", sysRR.Code)
}
if !strings.Contains(sysRR.Body.String(), "pgcreds-section") {
t.Error("system account detail page missing pgcreds-section")
}
// Human account detail must NOT include the pgcreds section.
humanReq := authenticatedGET(t, sessionToken, fmt.Sprintf("/accounts/%s", humanAcct.UUID))
humanRR := httptest.NewRecorder()
mux.ServeHTTP(humanRR, humanReq)
if humanRR.Code != http.StatusOK {
t.Fatalf("human account GET status = %d, want 200", humanRR.Code)
}
if strings.Contains(humanRR.Body.String(), "pgcreds-section") {
t.Error("human account detail page must not include pgcreds-section")
}
}

File diff suppressed because one or more lines are too long

View File

@@ -34,4 +34,10 @@
</div>
{{template "token_list" .}}
</div>
{{if eq (string .Account.AccountType) "system"}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Postgres Credentials</h2>
{{template "pgcreds_form" .}}
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,35 @@
{{define "pgcreds_form"}}
<div id="pgcreds-section">
{{if .PGCred}}
<dl style="display:grid;grid-template-columns:140px 1fr;gap:.5rem .75rem;font-size:.9rem;margin-bottom:1rem">
<dt class="text-muted">Host</dt><dd>{{.PGCred.PGHost}}:{{.PGCred.PGPort}}</dd>
<dt class="text-muted">Database</dt><dd>{{.PGCred.PGDatabase}}</dd>
<dt class="text-muted">Username</dt><dd>{{.PGCred.PGUsername}}</dd>
<dt class="text-muted">Password</dt><dd><em class="text-muted">stored (not shown)</em></dd>
<dt class="text-muted">Updated</dt><dd class="text-small">{{formatTime .PGCred.UpdatedAt}}</dd>
</dl>
{{else}}
<p class="text-muted text-small" style="margin-bottom:1rem">No credentials stored.</p>
{{end}}
<form hx-put="/accounts/{{.Account.UUID}}/pgcreds"
hx-target="#pgcreds-section" hx-swap="outerHTML">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<input class="form-control" type="text" name="host" placeholder="Host" required
value="{{if .PGCred}}{{.PGCred.PGHost}}{{end}}">
<input class="form-control" type="number" name="port" placeholder="Port (5432)"
min="1" max="65535"
value="{{if .PGCred}}{{.PGCred.PGPort}}{{end}}">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<input class="form-control" type="text" name="database" placeholder="Database" required
value="{{if .PGCred}}{{.PGCred.PGDatabase}}{{end}}">
<input class="form-control" type="text" name="username" placeholder="Username" required
value="{{if .PGCred}}{{.PGCred.PGUsername}}{{end}}">
</div>
<input class="form-control" type="password" name="password"
placeholder="Password (required to update)" required
style="margin-bottom:.5rem">
<button class="btn btn-sm btn-secondary" type="submit">Save Credentials</button>
</form>
</div>
{{end}}