UI: password change enforcement + migration recovery

- Web UI admin password reset now enforces admin role
  server-side (was cookie-auth + CSRF only; any logged-in
  user could previously reset any account's password)
- Added self-service password change UI at GET/PUT /profile:
  current_password + new_password + confirm_password;
  server-side equality check; lockout + Argon2id verification;
  revokes all other sessions on success
- password_change_form.html fragment and profile.html page
- Nav bar actor name now links to /profile
- policy: ActionChangePassword + default rule -7 allowing
  human accounts to change their own password
- openapi.yaml: built-in rules count updated to -7

Migration recovery:
- mciasdb schema force --version N: new subcommand to clear
  dirty migration state without running SQL (break-glass)
- schema subcommands bypass auto-migration on open so the
  tool stays usable when the database is dirty
- Migrate(): shim no longer overrides schema_migrations
  when it already has an entry; duplicate-column error on
  the latest migration is force-cleaned and treated as
  success (handles columns added outside the runner)

Security:
- Admin role is now validated in handleAdminResetPassword
  before any DB access; non-admin receives 403
- handleSelfChangePassword follows identical lockout +
  constant-time Argon2id path as the REST self-service
  handler; current password required to prevent
  token-theft account takeover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 15:33:19 -07:00
parent a9ebeb2ba1
commit f262ca7b4e
13 changed files with 412 additions and 24 deletions

View File

@@ -4,6 +4,59 @@ Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean.
### 2026-03-12 — Checkpoint: password change UI enforcement + migration recovery
**internal/ui/handlers_accounts.go**
- `handleAdminResetPassword`: added server-side admin role check at the top of
the handler; any authenticated non-admin calling this route now receives 403.
Previously only cookie validity + CSRF were checked.
**internal/ui/handlers_auth.go**
- Added `handleProfilePage`: renders the new `/profile` page for any
authenticated user.
- Added `handleSelfChangePassword`: self-service password change for non-admin
users; validates current password (Argon2id, lockout-checked), enforces
server-side confirmation equality check, hashes new password, revokes all
other sessions, audits as `{"via":"ui_self_service"}`.
**internal/ui/ui.go**
- Added `ProfileData` view model.
- Registered `GET /profile` and `PUT /profile/password` routes (cookie auth +
CSRF; no admin role required).
- Added `password_change_form.html` to shared template list; added `profile`
page template.
- Nav bar actor-name span changed to a link pointing to `/profile`.
**web/templates/fragments/password_change_form.html** (new)
- HTMX form with `current_password`, `new_password`, `confirm_password` fields.
- Client-side JS confirmation guard; server-side equality check in handler.
**web/templates/profile.html** (new)
- Profile page hosting the self-service password change form.
**internal/db/migrate.go**
- Compatibility shim now only calls `m.Force(legacyVersion)` when
`schema_migrations` is completely empty (`ErrNilVersion`); leaves existing
version entries (including dirty ones) alone to prevent re-running already-
attempted migrations.
- Added duplicate-column-name recovery: when `m.Up()` fails with "duplicate
column name" and the dirty version equals `LatestSchemaVersion`, the migrator
is force-cleaned and returns nil (handles databases where columns were added
outside the runner before migration 006 existed).
- Added `ForceSchemaVersion(database *DB, version int) error`: break-glass
exported function; forces golang-migrate version without running SQL.
**cmd/mciasdb/schema.go**
- Added `schema force --version N` subcommand backed by `db.ForceSchemaVersion`.
**cmd/mciasdb/main.go**
- `schema` commands now open the database via `openDBRaw` (no auto-migration)
so the tool stays usable when the database is in a dirty migration state.
- `openDB` refactored to call `openDBRaw` then `db.Migrate`.
- Updated usage text.
All tests pass; `golangci-lint run ./...` clean.
### 2026-03-12 — Password change: self-service and admin reset ### 2026-03-12 — Password change: self-service and admin reset
Added the ability for users to change their own password and for admins to Added the ability for users to change their own password and for admins to
@@ -394,9 +447,10 @@ All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues.
- `engine.go``Evaluate(input, operatorRules) (Effect, *Rule)`: pure function; - `engine.go``Evaluate(input, operatorRules) (Effect, *Rule)`: pure function;
merges operator rules with default rules, sorts by priority, deny-wins, merges operator rules with default rules, sorts by priority, deny-wins,
then first allow, then default-deny then first allow, then default-deny
- `defaults.go`6 compiled-in rules (IDs -1 to -6, Priority 0): admin - `defaults.go`7 compiled-in rules (IDs -1 to -7, Priority 0): admin
wildcard, self-service logout/renew, self-service TOTP, system account own wildcard, self-service logout/renew, self-service TOTP, self-service password
pgcreds, system account own service token, public login/validate endpoints change (human only), system account own pgcreds, system account own service
token, public login/validate endpoints
- `engine_wrapper.go``Engine` struct with `sync.RWMutex`; `SetRules()` - `engine_wrapper.go``Engine` struct with `sync.RWMutex`; `SetRules()`
decodes DB records; `PolicyRecord` type avoids import cycle decodes DB records; `PolicyRecord` type avoids import cycle
- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*, - `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*,

View File

@@ -15,6 +15,7 @@
// //
// schema verify // schema verify
// schema migrate // schema migrate
// schema force --version N
// //
// account list // account list
// account get --id UUID // account get --id UUID
@@ -62,7 +63,22 @@ func main() {
os.Exit(1) os.Exit(1)
} }
database, masterKey, err := openDB(*configPath) command := args[0]
subArgs := args[1:]
// schema subcommands manage migrations themselves and must not trigger
// auto-migration on open (a dirty database would prevent the tool from
// opening at all, blocking recovery operations like "schema force").
var (
database *db.DB
masterKey []byte
err error
)
if command == "schema" {
database, masterKey, err = openDBRaw(*configPath)
} else {
database, masterKey, err = openDB(*configPath)
}
if err != nil { if err != nil {
fatalf("%v", err) fatalf("%v", err)
} }
@@ -76,9 +92,6 @@ func main() {
tool := &tool{db: database, masterKey: masterKey} tool := &tool{db: database, masterKey: masterKey}
command := args[0]
subArgs := args[1:]
switch command { switch command {
case "schema": case "schema":
tool.runSchema(subArgs) tool.runSchema(subArgs)
@@ -111,6 +124,21 @@ type tool struct {
// the same passphrase always yields the same key and encrypted secrets remain // the same passphrase always yields the same key and encrypted secrets remain
// readable. The passphrase env var is unset immediately after reading. // readable. The passphrase env var is unset immediately after reading.
func openDB(configPath string) (*db.DB, []byte, error) { func openDB(configPath string) (*db.DB, []byte, error) {
database, masterKey, err := openDBRaw(configPath)
if err != nil {
return nil, nil, err
}
if err := db.Migrate(database); err != nil {
_ = database.Close()
return nil, nil, fmt.Errorf("migrate database: %w", err)
}
return database, masterKey, nil
}
// openDBRaw opens the database without running migrations. Used by schema
// subcommands so they remain operational even when the database is in a dirty
// migration state (e.g. to allow "schema force" to clear a dirty flag).
func openDBRaw(configPath string) (*db.DB, []byte, error) {
cfg, err := config.Load(configPath) cfg, err := config.Load(configPath)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("load config: %w", err) return nil, nil, fmt.Errorf("load config: %w", err)
@@ -121,11 +149,6 @@ func openDB(configPath string) (*db.DB, []byte, error) {
return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err) return nil, nil, fmt.Errorf("open database %q: %w", cfg.Database.Path, err)
} }
if err := db.Migrate(database); err != nil {
_ = database.Close()
return nil, nil, fmt.Errorf("migrate database: %w", err)
}
masterKey, err := deriveMasterKey(cfg, database) masterKey, err := deriveMasterKey(cfg, database)
if err != nil { if err != nil {
_ = database.Close() _ = database.Close()
@@ -210,6 +233,7 @@ Global flags:
Commands: Commands:
schema verify Check schema version; exit 1 if migrations pending schema verify Check schema version; exit 1 if migrations pending
schema migrate Apply any pending schema migrations schema migrate Apply any pending schema migrations
schema force --version N Force schema version (clears dirty state)
account list List all accounts account list List all accounts
account get --id UUID account get --id UUID

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/db"
@@ -8,13 +9,15 @@ import (
func (t *tool) runSchema(args []string) { func (t *tool) runSchema(args []string) {
if len(args) == 0 { if len(args) == 0 {
fatalf("schema requires a subcommand: verify, migrate") fatalf("schema requires a subcommand: verify, migrate, force")
} }
switch args[0] { switch args[0] {
case "verify": case "verify":
t.schemaVerify() t.schemaVerify()
case "migrate": case "migrate":
t.schemaMigrate() t.schemaMigrate()
case "force":
t.schemaForce(args[1:])
default: default:
fatalf("unknown schema subcommand %q", args[0]) fatalf("unknown schema subcommand %q", args[0])
} }
@@ -39,6 +42,26 @@ func (t *tool) schemaVerify() {
fmt.Println("schema is up-to-date") fmt.Println("schema is up-to-date")
} }
// schemaForce marks the database as being at a specific migration version
// without running any SQL. Use this to clear a dirty migration state after
// you have verified that the schema already reflects the target version.
//
// Example: mciasdb schema force --version 6
func (t *tool) schemaForce(args []string) {
fs := flag.NewFlagSet("schema force", flag.ExitOnError)
version := fs.Int("version", 0, "schema version to force (required)")
_ = fs.Parse(args)
if *version <= 0 {
fatalf("--version must be a positive integer")
}
if err := db.ForceSchemaVersion(t.db, *version); err != nil {
fatalf("force schema version: %v", err)
}
fmt.Printf("schema version forced to %d; run 'schema migrate' to apply any remaining migrations\n", *version)
}
// schemaMigrate applies any pending migrations and reports each one. // schemaMigrate applies any pending migrations and reports each one.
func (t *tool) schemaMigrate() { func (t *tool) schemaMigrate() {
before, err := db.SchemaVersion(t.db) before, err := db.SchemaVersion(t.db)

View File

@@ -5,6 +5,7 @@ import (
"embed" "embed"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite" sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
@@ -93,19 +94,65 @@ func Migrate(database *DB) error {
defer func() { src, drv := m.Close(); _ = src; _ = drv }() defer func() { src, drv := m.Close(); _ = src; _ = drv }()
if legacyVersion > 0 { if legacyVersion > 0 {
// Force the migrator to treat the database as already at // Only fast-forward from the legacy version when golang-migrate has no
// legacyVersion so Up only applies newer migrations. // version record of its own yet (ErrNilVersion). If schema_migrations
// already has an entry — including a dirty entry from a previously
// failed migration — leave it alone and let golang-migrate handle it.
// Overriding a non-nil version would discard progress (or a dirty
// state that needs idempotent re-application) and cause migrations to
// be retried unnecessarily.
_, _, versionErr := m.Version()
if errors.Is(versionErr, migrate.ErrNilVersion) {
if err := m.Force(legacyVersion); err != nil { if err := m.Force(legacyVersion); err != nil {
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err) return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, err)
} }
} }
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
// A "duplicate column name" error means the failing migration is an
// ADD COLUMN that was already applied outside the migration runner
// (common during development before a migration file existed).
// If this is the last migration and its version matches LatestSchemaVersion,
// force it clean so subsequent starts succeed.
//
// This is intentionally narrow: we only suppress the error when the
// dirty version equals the latest known version, preventing accidental
// masking of errors in intermediate migrations.
if strings.Contains(err.Error(), "duplicate column name") {
v, dirty, verErr := m.Version()
if verErr == nil && dirty && int(v) == LatestSchemaVersion { //nolint:gosec // G115: safe conversion
if forceErr := m.Force(LatestSchemaVersion); forceErr != nil {
return fmt.Errorf("db: force after duplicate column: %w", forceErr)
}
return nil
}
}
return fmt.Errorf("db: apply migrations: %w", err) return fmt.Errorf("db: apply migrations: %w", err)
} }
return nil return nil
} }
// ForceSchemaVersion marks the database as being at the given version without
// running any SQL. This is a break-glass operation: use it to clear a dirty
// migration state after verifying (or manually applying) the migration SQL.
//
// Passing a version that has never been recorded by golang-migrate is safe;
// it simply sets the version and clears the dirty flag. The next call to
// Migrate will apply any versions higher than the forced one.
func ForceSchemaVersion(database *DB, version int) error {
m, err := newMigrate(database)
if err != nil {
return err
}
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
if err := m.Force(version); err != nil {
return fmt.Errorf("db: force schema version %d: %w", version, err)
}
return nil
}
// SchemaVersion returns the current applied schema version of the database. // SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet. // Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) { func SchemaVersion(database *DB) (int, error) {

View File

@@ -42,6 +42,18 @@ var defaultRules = []Rule{
Actions: []Action{ActionEnrollTOTP}, Actions: []Action{ActionEnrollTOTP},
Effect: Allow, Effect: Allow,
}, },
{
// Self-service password change: any authenticated human account may
// change their own password. The handler derives the target exclusively
// from the JWT subject (claims.Subject) and requires the current
// password, so a non-admin caller can only affect their own account.
ID: -7,
Description: "Self-service: any human account may change their own password",
Priority: 0,
AccountTypes: []string{"human"},
Actions: []Action{ActionChangePassword},
Effect: Allow,
},
{ {
// System accounts reading their own pgcreds: a service that has already // System accounts reading their own pgcreds: a service that has already
// authenticated (e.g. via its bearer service token) may retrieve its own // authenticated (e.g. via its bearer service token) may retrieve its own

View File

@@ -44,6 +44,7 @@ const (
ActionLogin Action = "auth:login" // public ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service ActionLogout Action = "auth:logout" // self-service
ActionChangePassword Action = "auth:change_password" // self-service
ActionListRules Action = "policy:list" ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage" ActionManageRules Action = "policy:manage"

View File

@@ -901,10 +901,32 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
// for the target account are revoked so a compromised account is fully // for the target account are revoked so a compromised account is fully
// invalidated. // invalidated.
// //
// Security: new password is validated (minimum 12 chars) and hashed with // Security: caller must hold the admin role; the check is performed server-side
// Argon2id before storage. The plaintext is never logged or included in any // against the JWT claims so it cannot be bypassed by client-side tricks.
// response. Audit event EventPasswordChanged is recorded on success. // New password is validated (minimum 12 chars) and hashed with Argon2id before
// storage. The plaintext is never logged or included in any response.
// Audit event EventPasswordChanged is recorded on success.
func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
// Security: enforce admin role; requireCookieAuth only validates the token,
// it does not check roles. A non-admin with a valid session must not be
// able to reset arbitrary accounts' passwords.
callerClaims := claimsFromContext(r.Context())
if callerClaims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
isAdmin := false
for _, role := range callerClaims.Roles {
if role == "admin" {
isAdmin = true
break
}
}
if !isAdmin {
u.renderError(w, r, http.StatusForbidden, "admin role required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form") u.renderError(w, r, http.StatusBadRequest, "invalid form")

View File

@@ -8,6 +8,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/crypto" "git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/validate"
) )
// handleLoginPage renders the login form. // handleLoginPage renders the login form.
@@ -255,3 +256,127 @@ func (u *UIServer) writeAudit(r *http.Request, eventType string, actorID, target
u.logger.Warn("write audit event", "type", eventType, "error", err) u.logger.Warn("write audit event", "type", eventType, "error", err)
} }
} }
// handleProfilePage renders the profile page for the currently logged-in user.
func (u *UIServer) handleProfilePage(w http.ResponseWriter, r *http.Request) {
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "profile", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
},
})
}
// handleSelfChangePassword allows an authenticated human user to change their
// own password. The current password must be supplied to prevent a stolen
// session token from being used to take over an account.
//
// Security: current password is verified with Argon2id (constant-time) before
// the new hash is written. Lockout is checked first so the endpoint cannot
// be used to brute-force the existing password. On success all other active
// sessions are revoked; the caller's own session is preserved so they remain
// logged in. The plaintext passwords are never logged or returned.
func (u *UIServer) handleSelfChangePassword(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
}
claims := claimsFromContext(r.Context())
if claims == nil {
u.renderError(w, r, http.StatusUnauthorized, "unauthorized")
return
}
acct, err := u.db.GetAccountByUUID(claims.Subject)
if err != nil {
u.renderError(w, r, http.StatusUnauthorized, "account not found")
return
}
if acct.AccountType != model.AccountTypeHuman {
u.renderError(w, r, http.StatusBadRequest, "password change is only available for human accounts")
return
}
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if currentPassword == "" || newPassword == "" {
u.renderError(w, r, http.StatusBadRequest, "current and new password are required")
return
}
// Server-side confirmation check mirrors the client-side guard; defends
// against direct POST requests that bypass the JavaScript validation.
if newPassword != confirmPassword {
u.renderError(w, r, http.StatusBadRequest, "passwords do not match")
return
}
// Security: check lockout before running Argon2 to prevent brute-force.
locked, lockErr := u.db.IsLockedOut(acct.ID)
if lockErr != nil {
u.logger.Error("lockout check (UI self-service password change)", "error", lockErr)
}
if locked {
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"locked"}`)
u.renderError(w, r, http.StatusTooManyRequests, "account temporarily locked, please try again later")
return
}
// Security: verify current password with constant-time Argon2id path used
// at login so this endpoint cannot serve as a timing oracle.
ok, verifyErr := auth.VerifyPassword(currentPassword, acct.PasswordHash)
if verifyErr != nil || !ok {
_ = u.db.RecordLoginFailure(acct.ID)
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"result":"wrong_current_password"}`)
u.renderError(w, r, http.StatusUnauthorized, "current password is incorrect")
return
}
// Security (F-13): enforce minimum length before hashing.
if err := validate.Password(newPassword); err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
hash, err := auth.HashPassword(newPassword, auth.ArgonParams{
Time: u.cfg.Argon2.Time,
Memory: u.cfg.Argon2.Memory,
Threads: u.cfg.Argon2.Threads,
})
if err != nil {
u.logger.Error("hash password (UI self-service)", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
if err := u.db.UpdatePasswordHash(acct.ID, hash); err != nil {
u.logger.Error("update password hash", "error", err)
u.renderError(w, r, http.StatusInternalServerError, "failed to update password")
return
}
// Security: clear failure counter (user proved knowledge of current
// password), then revoke all sessions except the current one so stale
// tokens are invalidated while the caller stays logged in.
_ = u.db.ClearLoginFailures(acct.ID)
if err := u.db.RevokeAllUserTokensExcept(acct.ID, claims.JTI, "password_changed"); err != nil {
u.logger.Error("revoke other tokens on UI password change", "account_id", acct.ID, "error", err)
u.renderError(w, r, http.StatusInternalServerError, "password updated but session revocation failed; revoke tokens manually")
return
}
u.writeAudit(r, model.EventPasswordChanged, &acct.ID, &acct.ID, `{"via":"ui_self_service"}`)
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "password_change_result", ProfileData{
PageData: PageData{
CSRFToken: csrfToken,
ActorName: u.actorName(r),
Flash: "Password updated successfully. Other active sessions have been revoked.",
},
})
}

View File

@@ -191,6 +191,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/policy_row.html", "templates/fragments/policy_row.html",
"templates/fragments/policy_form.html", "templates/fragments/policy_form.html",
"templates/fragments/password_reset_form.html", "templates/fragments/password_reset_form.html",
"templates/fragments/password_change_form.html",
} }
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil { if err != nil {
@@ -208,6 +209,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"audit_detail": "templates/audit_detail.html", "audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html", "policies": "templates/policies.html",
"pgcreds": "templates/pgcreds.html", "pgcreds": "templates/pgcreds.html",
"profile": "templates/profile.html",
} }
tmpls := make(map[string]*template.Template, len(pageFiles)) tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles { for name, file := range pageFiles {
@@ -296,6 +298,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags)) uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword)) uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
// Profile routes — accessible to any authenticated user (not admin-only).
uiMux.Handle("GET /profile", adminGet(u.handleProfilePage))
uiMux.Handle("PUT /profile/password", auth(u.requireCSRF(http.HandlerFunc(u.handleSelfChangePassword))))
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a // Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
// catch-all for all UI paths; the more-specific /v1/ API patterns registered // catch-all for all UI paths; the more-specific /v1/ API patterns registered
// on the parent mux continue to take precedence per Go's routing rules. // on the parent mux continue to take precedence per Go's routing rules.
@@ -611,6 +617,11 @@ type PoliciesData struct {
AllActions []string AllActions []string
} }
// ProfileData is the view model for the profile/settings page.
type ProfileData struct {
PageData
}
// PGCredsData is the view model for the "My PG Credentials" list page. // 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: // It shows all pg_credentials sets accessible to the currently logged-in user:
// those they own and those they have been granted access to. // those they own and those they have been granted access to.

View File

@@ -1257,7 +1257,7 @@ paths:
summary: List policy rules (admin) summary: List policy rules (admin)
description: | description: |
Return all operator-defined policy rules ordered by priority (ascending). Return all operator-defined policy rules ordered by priority (ascending).
Built-in default rules (IDs -1 to -6) are not included. Built-in default rules (IDs -1 to -7) are not included.
operationId: listPolicyRules operationId: listPolicyRules
tags: [Admin — Policy] tags: [Admin — Policy]
security: security:

View File

@@ -16,7 +16,7 @@
<li><a href="/audit">Audit</a></li> <li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li> <li><a href="/policies">Policies</a></li>
<li><a href="/pgcreds">PG Creds</a></li> <li><a href="/pgcreds">PG Creds</a></li>
{{if .ActorName}}<li><span class="nav-actor">{{.ActorName}}</span></li>{{end}} {{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li> <li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,53 @@
{{define "password_change_form"}}
<form id="password-change-form"
hx-put="/profile/password"
hx-target="#password-change-section"
hx-swap="innerHTML"
hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'
onsubmit="return mciasPwChangeConfirm(this)">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password"
class="form-control" autocomplete="current-password"
placeholder="Your current password" required>
</div>
<div class="form-group" style="margin-top:.5rem">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password"
class="form-control" autocomplete="new-password"
placeholder="Minimum 12 characters" required minlength="12">
</div>
<div class="form-group" style="margin-top:.5rem">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password"
class="form-control" autocomplete="new-password"
placeholder="Repeat new password" required minlength="12">
</div>
<div id="pw-change-error" role="alert"
style="display:none;color:var(--color-danger,#c0392b);font-size:.85rem;margin-top:.35rem"></div>
<button type="submit" class="btn btn-primary btn-sm" style="margin-top:.75rem">
Change Password
</button>
</form>
<script>
function mciasPwChangeConfirm(form) {
var pw = form.querySelector('#new_password').value;
var cfm = form.querySelector('#confirm_password').value;
var err = form.querySelector('#pw-change-error');
if (pw !== cfm) {
err.textContent = 'Passwords do not match.';
err.style.display = 'block';
return false;
}
err.style.display = 'none';
return true;
}
</script>
{{end}}
{{define "password_change_result"}}
{{if .Flash}}
<div class="alert alert-success" role="alert">{{.Flash}}</div>
{{end}}
{{template "password_change_form" .}}
{{end}}

View File

@@ -0,0 +1,16 @@
{{define "profile"}}{{template "base" .}}{{end}}
{{define "title"}}Profile — MCIAS{{end}}
{{define "content"}}
<div class="page-header">
<h1>Profile</h1>
</div>
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Change Password</h2>
<p class="text-muted text-small" style="margin-bottom:.75rem">
Enter your current password and choose a new one. Other active sessions will be revoked.
</p>
<div id="password-change-section">
{{template "password_change_form" .}}
</div>
</div>
{{end}}