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" ) // knownRoles lists the built-in roles shown as checkboxes in the roles editor. var knownRoles = []string{"admin", "user", "service"} // handleAccountsList renders the accounts list page. func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) { csrfToken, err := u.setCSRFCookies(w) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } accounts, err := u.db.ListAccounts() if err != nil { u.renderError(w, r, http.StatusInternalServerError, "failed to load accounts") return } u.render(w, "accounts", AccountsData{ PageData: PageData{CSRFToken: csrfToken}, Accounts: accounts, }) } // handleCreateAccount creates a new account and returns the account_row fragment. func (u *UIServer) handleCreateAccount(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 } username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") accountTypeStr := r.FormValue("account_type") // Security (F-12): validate username length and character set. if err := validate.Username(username); err != nil { u.renderError(w, r, http.StatusBadRequest, err.Error()) return } accountType := model.AccountTypeHuman if accountTypeStr == string(model.AccountTypeSystem) { accountType = model.AccountTypeSystem } var passwordHash string if password != "" { // Security (F-13): enforce minimum length before hashing. if err := validate.Password(password); err != nil { u.renderError(w, r, http.StatusBadRequest, err.Error()) return } argonCfg := auth.ArgonParams{ Time: u.cfg.Argon2.Time, Memory: u.cfg.Argon2.Memory, Threads: u.cfg.Argon2.Threads, } var err error passwordHash, err = auth.HashPassword(password, argonCfg) if err != nil { u.logger.Error("hash password", "error", err) u.renderError(w, r, http.StatusInternalServerError, "internal error") return } } else if accountType == model.AccountTypeHuman { u.renderError(w, r, http.StatusBadRequest, "password is required for human accounts") return } claims := claimsFromContext(r.Context()) var actorID *int64 if claims != nil { acct, err := u.db.GetAccountByUUID(claims.Subject) if err == nil { actorID = &acct.ID } } acct, err := u.db.CreateAccount(username, accountType, passwordHash) if err != nil { u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create account: %v", err)) return } u.writeAudit(r, model.EventAccountCreated, actorID, &acct.ID, fmt.Sprintf(`{"username":%q,"type":%q}`, username, accountType)) u.render(w, "account_row", acct) } // handleAccountDetail renders the account detail page. func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { csrfToken, err := u.setCSRFCookies(w) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } id := r.PathValue("id") acct, err := u.db.GetAccountByUUID(id) if err != nil { u.renderError(w, r, http.StatusNotFound, "account not found") return } roles, err := u.db.GetRoles(acct.ID) if err != nil { u.renderError(w, r, http.StatusInternalServerError, "failed to load roles") return } tokens, err := u.db.ListTokensForAccount(acct.ID) if err != nil { u.logger.Warn("list tokens for account", "error", err) 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. } tags, err := u.db.GetAccountTags(acct.ID) if err != nil { u.logger.Warn("get account tags", "error", err) tags = nil } u.render(w, "account_detail", AccountDetailData{ PageData: PageData{CSRFToken: csrfToken}, Account: acct, Roles: roles, AllRoles: knownRoles, Tokens: tokens, PGCred: pgCred, Tags: tags, }) } // handleUpdateAccountStatus toggles an account between active and inactive. func (u *UIServer) handleUpdateAccountStatus(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 } statusStr := r.FormValue("status") var newStatus model.AccountStatus switch statusStr { case string(model.AccountStatusActive): newStatus = model.AccountStatusActive case string(model.AccountStatusInactive): newStatus = model.AccountStatusInactive default: u.renderError(w, r, http.StatusBadRequest, "invalid status") return } if err := u.db.UpdateAccountStatus(acct.ID, newStatus); err != nil { u.renderError(w, r, http.StatusInternalServerError, "update failed") return } acct.Status = newStatus 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.EventAccountUpdated, actorID, &acct.ID, fmt.Sprintf(`{"status":%q}`, newStatus)) // Respond with the updated row (for HTMX outerHTML swap on accounts list) // or updated status cell (for HTMX innerHTML swap on account detail). // The hx-target in accounts.html targets the whole ; in account_detail.html // it targets #status-cell. We detect which by checking the request path context. if strings.Contains(r.Header.Get("HX-Target"), "status-cell") || r.Header.Get("HX-Target") == "status-cell" { data := AccountDetailData{ PageData: PageData{CSRFToken: ""}, Account: acct, } u.render(w, "account_status", data) } else { u.render(w, "account_row", acct) } } // handleDeleteAccount soft-deletes an account and returns empty body (HTMX removes row). func (u *UIServer) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") acct, err := u.db.GetAccountByUUID(id) if err != nil { u.renderError(w, r, http.StatusNotFound, "account not found") return } if err := u.db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil { u.renderError(w, r, http.StatusInternalServerError, "delete failed") return } // Revoke all active tokens for the deleted account. if err := u.db.RevokeAllUserTokens(acct.ID, "account_deleted"); err != nil { u.logger.Warn("revoke tokens for deleted account", "account_id", acct.ID, "error", err) } 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.EventAccountDeleted, actorID, &acct.ID, "") // Return empty body; HTMX will remove the row via hx-swap="outerHTML". w.WriteHeader(http.StatusOK) } // handleRolesEditForm returns the roles editor fragment (GET — no CSRF check needed). func (u *UIServer) handleRolesEditForm(w http.ResponseWriter, r *http.Request) { csrfToken, err := u.setCSRFCookies(w) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } id := r.PathValue("id") acct, err := u.db.GetAccountByUUID(id) if err != nil { u.renderError(w, r, http.StatusNotFound, "account not found") return } roles, err := u.db.GetRoles(acct.ID) if err != nil { u.renderError(w, r, http.StatusInternalServerError, "failed to load roles") return } u.render(w, "roles_editor", AccountDetailData{ PageData: PageData{CSRFToken: csrfToken}, Account: acct, Roles: roles, AllRoles: knownRoles, }) } // handleSetRoles replaces the full role set for an account. func (u *UIServer) handleSetRoles(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 } // Collect checked roles + optional custom role. roles := r.Form["roles"] // multi-value from checkboxes if custom := strings.TrimSpace(r.FormValue("custom_role")); custom != "" { roles = append(roles, custom) } claims := claimsFromContext(r.Context()) var actorID *int64 if claims != nil { actor, err := u.db.GetAccountByUUID(claims.Subject) if err == nil { actorID = &actor.ID } } if err := u.db.SetRoles(acct.ID, roles, actorID); err != nil { u.renderError(w, r, http.StatusInternalServerError, "failed to set roles") return } u.writeAudit(r, model.EventRoleGranted, actorID, &acct.ID, fmt.Sprintf(`{"roles":%q}`, strings.Join(roles, ","))) csrfToken, err := u.setCSRFCookies(w) if err != nil { csrfToken = "" } u.render(w, "roles_editor", AccountDetailData{ PageData: PageData{CSRFToken: csrfToken}, Account: acct, Roles: roles, AllRoles: knownRoles, }) } // handleRevokeToken revokes a specific token by JTI. func (u *UIServer) handleRevokeToken(w http.ResponseWriter, r *http.Request) { jti := r.PathValue("jti") if jti == "" { u.renderError(w, r, http.StatusBadRequest, "missing JTI") return } if err := u.db.RevokeToken(jti, "ui_revoke"); err != nil { u.renderError(w, r, http.StatusInternalServerError, "revoke failed") 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.EventTokenRevoked, actorID, nil, fmt.Sprintf(`{"jti":%q,"reason":"ui_revoke"}`, jti)) // Return empty body; HTMX removes the row. 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") acct, err := u.db.GetAccountByUUID(id) if err != nil { u.renderError(w, r, http.StatusNotFound, "account not found") return } if acct.AccountType != model.AccountTypeSystem { u.renderError(w, r, http.StatusBadRequest, "only system accounts can have service tokens") return } roles, err := u.db.GetRoles(acct.ID) if err != nil { u.renderError(w, r, http.StatusInternalServerError, "failed to load roles") return } // Security: revoke the previous system token before issuing a new one (F-16). // This matches the pattern in the REST handleTokenIssue and gRPC IssueServiceToken // so that old tokens do not remain valid after rotation. existing, err := u.db.GetSystemToken(acct.ID) if err == nil { _ = u.db.RevokeToken(existing.JTI, "rotated") } expiry := u.cfg.ServiceExpiry() tokenStr, claims, err := u.issueToken(acct.UUID, roles, expiry) if err != nil { u.logger.Error("issue system token", "error", err) u.renderError(w, r, http.StatusInternalServerError, "failed to issue token") return } if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { u.logger.Error("track system token", "error", err) u.renderError(w, r, http.StatusInternalServerError, "failed to track token") return } // Store as system token for easy retrieval. if err := u.db.SetSystemToken(acct.ID, claims.JTI, claims.ExpiresAt); err != nil { u.logger.Warn("set system token record", "error", err) } actorClaims := claimsFromContext(r.Context()) var actorID *int64 if actorClaims != nil { actor, err := u.db.GetAccountByUUID(actorClaims.Subject) if err == nil { actorID = &actor.ID } } u.writeAudit(r, model.EventTokenIssued, actorID, &acct.ID, fmt.Sprintf(`{"jti":%q,"via":"ui_system_token"}`, claims.JTI)) // Re-fetch token list including the new token. tokens, err := u.db.ListTokensForAccount(acct.ID) if err != nil { u.logger.Warn("list tokens after issue", "error", err) tokens = nil } csrfToken, err := u.setCSRFCookies(w) if err != nil { csrfToken = "" } // Flash the raw token once at the top so the operator can copy it. u.render(w, "token_list", AccountDetailData{ PageData: PageData{ CSRFToken: csrfToken, Flash: fmt.Sprintf("Token issued. Copy now — it will not be shown again: %s", tokenStr), }, Account: acct, Tokens: tokens, }) }