- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
pgcreds_form.html and tags_editor.html to shared template set; remove
unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
pgcreds_form fragment; password never echoed; load PG creds and tags in
handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
+ set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
matching handleCreatePolicyRule (roles/account_types/actions multi-select,
resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
550 lines
16 KiB
Go
550 lines
16 KiB
Go
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 <tr>; 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,
|
|
})
|
|
}
|