diff --git a/pkg/models/admin_project_list.go b/pkg/models/admin_project_list.go
new file mode 100644
index 000000000..fe7e08277
--- /dev/null
+++ b/pkg/models/admin_project_list.go
@@ -0,0 +1,69 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package models
+
+import (
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
+
+ "xorm.io/xorm"
+)
+
+// AdminProjectList overrides ReadAll to return every project on the instance;
+// non-ReadAll methods inherit from Project and are gated by RequireInstanceAdmin.
+type AdminProjectList struct {
+ Project
+}
+
+// ReassignProjectOwner refuses owners scheduled for deletion because DeleteUser cascades to their projects.
+func ReassignProjectOwner(s *xorm.Session, projectID, newOwnerID int64) (*Project, error) {
+ p, err := GetProjectSimpleByID(s, projectID)
+ if err != nil {
+ return nil, err
+ }
+
+ newOwner, err := user.GetUserByID(s, newOwnerID)
+ if err != nil {
+ return nil, err
+ }
+ if !newOwner.DeletionScheduledAt.IsZero() {
+ return nil, ErrInvalidData{Message: "new owner is scheduled for deletion"}
+ }
+
+ p.OwnerID = newOwnerID
+ if _, err := s.ID(p.ID).Cols("owner_id").Update(p); err != nil {
+ return nil, err
+ }
+ p.Owner = newOwner
+ return p, nil
+}
+
+// ReadAll returns every project on the instance, archived included.
+// @Summary List projects (admin)
+// @Description Paginated list of every project on the instance, regardless of ownership.
+// @tags admin
+// @Produce json
+// @Security JWTKeyAuth
+// @Param page query int false "Page number, defaults to 1."
+// @Param per_page query int false "Items per page, defaults to the service setting."
+// @Param s query string false "Search projects by title, description or identifier."
+// @Success 200 {array} models.Project
+// @Failure 404 {object} web.HTTPError
+// @Router /admin/projects [get]
+func (l *AdminProjectList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPage int) (interface{}, int, int64, error) {
+ return ListAllProjects(s, search, page, perPage, true)
+}
diff --git a/pkg/routes/api/v1/admin/projects.go b/pkg/routes/api/v1/admin/projects.go
new file mode 100644
index 000000000..9571c8d63
--- /dev/null
+++ b/pkg/routes/api/v1/admin/projects.go
@@ -0,0 +1,67 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package admin
+
+import (
+ "net/http"
+ "strconv"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "github.com/labstack/echo/v5"
+)
+
+type OwnerPatch struct {
+ OwnerID int64 `json:"owner_id"`
+}
+
+// PatchProjectOwner reassigns a project's owner.
+// @Summary Reassign project owner (admin)
+// @Description Reassign a project's owner. The existing update endpoint doesn't allow owner changes — this is the admin-only escape hatch.
+// @tags admin
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param id path int true "Project ID"
+// @Param body body admin.OwnerPatch true "New owner"
+// @Success 200 {object} models.Project
+// @Failure 400 {object} web.HTTPError
+// @Failure 404 {object} web.HTTPError
+// @Router /admin/projects/{id}/owner [patch]
+func PatchProjectOwner(c *echo.Context) error {
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil || id < 1 {
+ return models.ErrProjectDoesNotExist{ID: id}
+ }
+
+ body := &OwnerPatch{}
+ if err := c.Bind(body); err != nil || body.OwnerID < 1 {
+ return models.ErrInvalidData{Message: "invalid body"}
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ p, err := models.ReassignProjectOwner(s, id, body.OwnerID)
+ if err != nil {
+ return err
+ }
+ if err := s.Commit(); err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, p)
+}
diff --git a/pkg/routes/api/v1/admin/users.go b/pkg/routes/api/v1/admin/users.go
new file mode 100644
index 000000000..f9392b772
--- /dev/null
+++ b/pkg/routes/api/v1/admin/users.go
@@ -0,0 +1,116 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package admin
+
+import (
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/user"
+ "code.vikunja.io/api/pkg/web"
+
+ "xorm.io/xorm"
+)
+
+// User re-exposes fields hidden by the default user.User JSON view.
+type User struct {
+ *user.User
+ IsAdmin bool `json:"is_admin"`
+ Status user.Status `json:"status"`
+ Issuer string `json:"issuer"`
+ Subject string `json:"subject,omitempty"`
+ AuthProvider string `json:"auth_provider,omitempty"`
+}
+
+func newAdminUser(u *user.User, providers []*openid.Provider) *User {
+ return &User{
+ User: u,
+ IsAdmin: u.IsAdmin,
+ Status: u.Status,
+ Issuer: u.Issuer,
+ Subject: u.Subject,
+ AuthProvider: resolveAuthProvider(u, providers),
+ }
+}
+
+func resolveAuthProvider(u *user.User, providers []*openid.Provider) string {
+ switch u.Issuer {
+ case "", user.IssuerLocal:
+ return ""
+ case user.IssuerLDAP:
+ return "LDAP"
+ }
+ for _, provider := range providers {
+ issuerURL, err := provider.Issuer()
+ if err != nil {
+ continue
+ }
+ if issuerURL == u.Issuer {
+ return provider.Name
+ }
+ }
+ return u.Issuer
+}
+
+// UserList backs the admin list-users route via handler.ReadAllWeb; only ReadAll is used.
+type UserList struct {
+ web.CRUDable `xorm:"-" json:"-"`
+ web.Permissions `xorm:"-" json:"-"`
+}
+
+// ReadAll returns paginated users, optionally filtered by username/email.
+// @Summary List users (admin)
+// @Description Paginated list of all users on the instance. Supports search by username/email. Exposes fields hidden from the normal user API (is_admin, status).
+// @tags admin
+// @Produce json
+// @Security JWTKeyAuth
+// @Param s query string false "Search string matched against username and email."
+// @Param page query int false "Page number, defaults to 1."
+// @Param per_page query int false "Items per page, defaults to the service setting."
+// @Success 200 {array} admin.User
+// @Failure 404 {object} web.HTTPError
+// @Router /admin/users [get]
+func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPage int) (interface{}, int, int64, error) {
+ finder := s.Limit(perPage, (page-1)*perPage).OrderBy("id ASC")
+ counter := s
+ if search != "" {
+ q := "%" + search + "%"
+ finder = finder.Where("username LIKE ? OR email LIKE ?", q, q)
+ counter = s.Where("username LIKE ? OR email LIKE ?", q, q)
+ }
+
+ var users []*user.User
+ if err := finder.Find(&users); err != nil {
+ return nil, 0, 0, err
+ }
+
+ totalCount, err := counter.Count(&user.User{})
+ if err != nil {
+ return nil, 0, 0, err
+ }
+
+ providers, err := openid.GetAllProviders()
+ if err != nil {
+ return nil, 0, 0, err
+ }
+
+ out := make([]*User, 0, len(users))
+ for _, u := range users {
+ out = append(out, newAdminUser(u, providers))
+ }
+ return out, len(out), totalCount, nil
+}
+
+func (*UserList) CanRead(*xorm.Session, web.Auth) (bool, int, error) { return true, 0, nil }
diff --git a/pkg/routes/api/v1/admin/users_admin.go b/pkg/routes/api/v1/admin/users_admin.go
new file mode 100644
index 000000000..5f31b269f
--- /dev/null
+++ b/pkg/routes/api/v1/admin/users_admin.go
@@ -0,0 +1,96 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package admin
+
+import (
+ "net/http"
+ "strconv"
+
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/user"
+ "github.com/labstack/echo/v5"
+)
+
+type IsAdminPatch struct {
+ // Pointer to distinguish "omitted" from false; an empty body would silently demote otherwise.
+ IsAdmin *bool `json:"is_admin"`
+}
+
+// PatchAdmin toggles a user's instance-admin flag.
+// @Summary Promote or demote a user (admin)
+// @Description Toggle the instance-admin flag on a user. Demoting the last remaining admin is refused with 400.
+// @tags admin
+// @Accept json
+// @Produce json
+// @Security JWTKeyAuth
+// @Param id path int true "User ID"
+// @Param body body admin.IsAdminPatch true "New admin value"
+// @Success 200 {object} admin.User
+// @Failure 400 {object} web.HTTPError
+// @Failure 404 {object} web.HTTPError
+// @Router /admin/users/{id}/admin [patch]
+func PatchAdmin(c *echo.Context) error {
+ idStr := c.Param("id")
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil || id < 1 {
+ return user.ErrUserDoesNotExist{UserID: id}
+ }
+
+ body := &IsAdminPatch{}
+ if err := c.Bind(body); err != nil {
+ return models.ErrInvalidData{Message: "invalid body"}
+ }
+ if body.IsAdmin == nil {
+ return models.ErrInvalidData{Message: "is_admin is required"}
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ target := &user.User{ID: id}
+ has, err := s.Get(target)
+ if err != nil {
+ return err
+ }
+ if !has {
+ return user.ErrUserDoesNotExist{UserID: id}
+ }
+
+ if !*body.IsAdmin {
+ if err := user.GuardLastAdmin(s, target); err != nil {
+ _ = s.Rollback()
+ return err
+ }
+ }
+
+ target.IsAdmin = *body.IsAdmin
+ if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil {
+ _ = s.Rollback()
+ return err
+ }
+ if err := s.Commit(); err != nil {
+ return err
+ }
+
+ providers, err := openid.GetAllProviders()
+ if err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, newAdminUser(target, providers))
+}