diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 746cf9c..7cfac7c 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -157,6 +157,7 @@ func (s *Server) routes(r chi.Router) { r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) r.Get("/api/alerts", s.handleAPIAlerts) r.Get("/api/audit", s.handleAPIAudit) + r.Post("/api/account/password", s.handleAPIAccountPassword) // Job log stream + download (read-only; any authenticated user). if s.deps.JobHub != nil { diff --git a/internal/server/http/ui_account.go b/internal/server/http/ui_account.go new file mode 100644 index 0000000..d23483e --- /dev/null +++ b/internal/server/http/ui_account.go @@ -0,0 +1,66 @@ +// ui_account.go — self-service account surface (password change). +// +// Routes (wired in server.go): +// +// POST /api/account/password — JSON change-password (mounted in viewer band) +// GET /settings/account — page (lands in Task F4) +// POST /settings/account — page submit (lands in Task F4) +package http + +import ( + "encoding/json" + stdhttp "net/http" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type passwordChangeRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (s *Server) handleAPIAccountPassword(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + var req passwordChangeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if len(req.NewPassword) < 12 { + writeJSONError(w, stdhttp.StatusBadRequest, "password_too_short", "min 12 chars") + return + } + // Skip current-password check when must_change_password is set — + // the user has no current password to know (only matters for the + // legacy reset-password path; setup-token path doesn't use this). + if !u.MustChangePassword { + if err := auth.VerifyPassword(u.PasswordHash, req.CurrentPassword); err != nil { + writeJSONError(w, stdhttp.StatusUnauthorized, "current_password_wrong", "") + return + } + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.password_changed", + TargetKind: ptr("user"), TargetID: &u.ID, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusOK) +} diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index e3ddeab..daa094b 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -275,3 +275,27 @@ func TestAPIUserForceLogout(t *testing.T) { t.Errorf("expected 0 remaining sessions, got %d", rr) } } + +func TestAPIAccountPasswordChange(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + uid := makeUser(t, srv, "alice", store.RoleViewer) + cookie := loginAs(t, srv, uid) + + body, _ := json.Marshal(map[string]string{ + "current_password": "test-password", + "new_password": "averylongpassword", + }) + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/account/password", bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Errorf("status: got %d body=%s", res.StatusCode, body) + } +}