package ui import ( "fmt" "net/http" "strings" "git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/model" ) // 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") if username == "" { u.renderError(w, r, http.StatusBadRequest, "username is required") return } accountType := model.AccountTypeHuman if accountTypeStr == string(model.AccountTypeSystem) { accountType = model.AccountTypeSystem } var passwordHash string if password != "" { 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 } u.render(w, "account_detail", AccountDetailData{ PageData: PageData{CSRFToken: csrfToken}, Account: acct, Roles: roles, AllRoles: knownRoles, Tokens: tokens, }) } // 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) } // 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, }) }