diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 6bad2c1..9b38e93 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -182,3 +183,90 @@ func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Reques SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, }) } + +func (s *Server) handleAPIUserGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + id := chi.URLParam(r, "id") + u, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + out := apiUser{ + ID: u.ID, Username: u.Username, Role: string(u.Role), + Email: u.Email, Disabled: u.DisabledAt != nil, + MustChangePassword: u.MustChangePassword, + CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if u.LastLoginAt != nil { + ll := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") + out.LastLoginAt = &ll + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(out) +} + +type patchUserRequest struct { + Role *string `json:"role,omitempty"` + Email *string `json:"email,omitempty"` +} + +func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + u, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + var req patchUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if req.Role != nil { + newRole, ok := validRole(*req.Role) + if !ok { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "") + return + } + // Last-admin guard: cannot demote the only enabled admin. + if u.Role == store.RoleAdmin && newRole != store.RoleAdmin && u.DisabledAt == nil { + n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) + if n <= 1 { + writeJSONError(w, stdhttp.StatusConflict, "last_admin", "") + return + } + } + if err := s.deps.Store.SetUserRole(r.Context(), id, newRole); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + } + if req.Email != nil { + em := strings.TrimSpace(*req.Email) + if em != "" { + if _, err := mail.ParseAddress(em); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error()) + return + } + } + if err := s.deps.Store.SetUserEmail(r.Context(), id, em); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.updated", TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusOK) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 15fe5a9..3ab89cc 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -253,6 +253,8 @@ func (s *Server) routes(r chi.Router) { r.Get("/api/users", s.handleAPIUsersList) r.Post("/api/users", s.handleAPIUserCreate) + r.Get("/api/users/{id}", s.handleAPIUserGet) + r.Patch("/api/users/{id}", s.handleAPIUserPatch) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index f55567a..376fcfb 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -104,3 +104,73 @@ func TestAPIUserCreateRejectsDuplicateEnabled(t *testing.T) { t.Errorf("status: got %d want 409", res.StatusCode) } } + +func TestAPIUserGet(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "carol", store.RoleViewer) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/users/"+target, nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } +} + +func TestAPIUserPatchRoleAndEmail(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "carol", store.RoleViewer) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{ + "role": "operator", "email": "carol@example.com", + }) + req, _ := stdhttp.NewRequest("PATCH", ts.URL+"/api/users/"+target, bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PATCH: %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) + } + got, _ := srv.deps.Store.GetUserByID(t.Context(), target) + if got.Role != store.RoleOperator { + t.Errorf("role: got %q", got.Role) + } + if got.Email == nil || *got.Email != "carol@example.com" { + t.Errorf("email: got %v", got.Email) + } +} + +func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{"role": "viewer"}) + req, _ := stdhttp.NewRequest("PATCH", ts.URL+"/api/users/"+adminID, bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PATCH: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +}