diff --git a/pkg/models/admin_overview.go b/pkg/models/admin_overview.go
new file mode 100644
index 000000000..082d6c81d
--- /dev/null
+++ b/pkg/models/admin_overview.go
@@ -0,0 +1,83 @@
+// 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/license"
+
+ "xorm.io/xorm"
+)
+
+type ShareCounts struct {
+ LinkShares int64 `json:"link_shares" readOnly:"true" doc:"Number of link shares across all projects."`
+ TeamShares int64 `json:"team_shares" readOnly:"true" doc:"Number of team-project shares."`
+ UserShares int64 `json:"user_shares" readOnly:"true" doc:"Number of user-project shares."`
+}
+
+type Overview struct {
+ Users int64 `json:"users" readOnly:"true" doc:"Total number of user accounts."`
+ Projects int64 `json:"projects" readOnly:"true" doc:"Total number of projects."`
+ Tasks int64 `json:"tasks" readOnly:"true" doc:"Total number of tasks."`
+ Teams int64 `json:"teams" readOnly:"true" doc:"Total number of teams."`
+ Shares ShareCounts `json:"shares" readOnly:"true" doc:"Aggregate share counts."`
+ License license.Info `json:"license" readOnly:"true" doc:"Snapshot of the instance license state."`
+}
+
+// BuildOverview returns aggregate instance counts plus the current license snapshot.
+func BuildOverview(s *xorm.Session) (*Overview, error) {
+ users, err := s.Table("users").Count()
+ if err != nil {
+ return nil, err
+ }
+ projects, err := s.Table("projects").Count()
+ if err != nil {
+ return nil, err
+ }
+ tasks, err := s.Table("tasks").Count()
+ if err != nil {
+ return nil, err
+ }
+ teams, err := s.Table("teams").Count()
+ if err != nil {
+ return nil, err
+ }
+ linkShares, err := s.Table("link_shares").Count()
+ if err != nil {
+ return nil, err
+ }
+ teamShares, err := s.Table("team_projects").Count()
+ if err != nil {
+ return nil, err
+ }
+ userShares, err := s.Table("users_projects").Count()
+ if err != nil {
+ return nil, err
+ }
+
+ return &Overview{
+ Users: users,
+ Projects: projects,
+ Tasks: tasks,
+ Teams: teams,
+ Shares: ShareCounts{
+ LinkShares: linkShares,
+ TeamShares: teamShares,
+ UserShares: userShares,
+ },
+ License: license.CurrentInfo(),
+ }, nil
+}
diff --git a/pkg/models/admin_user_create.go b/pkg/models/admin_user_create.go
new file mode 100644
index 000000000..a54d328a0
--- /dev/null
+++ b/pkg/models/admin_user_create.go
@@ -0,0 +1,80 @@
+// 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/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/user"
+
+ "xorm.io/xorm"
+)
+
+// CreateUserBody wraps user.APIUserPassword with admin-only fields.
+type CreateUserBody struct {
+ // The full name of the new user. Optional.
+ Name string `json:"name" doc:"The full name of the new user. Optional."`
+ // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.
+ Language string `json:"language" valid:"language" doc:"IETF BCP 47 language code; must exist in Vikunja."`
+ user.APIUserPassword
+ // Mark the new user as an instance admin.
+ IsAdmin bool `json:"is_admin" doc:"Mark the new user as an instance admin."`
+ // Activate the new user immediately without email confirmation.
+ SkipEmailConfirm bool `json:"skip_email_confirm" doc:"Activate the new user immediately, skipping email confirmation."`
+}
+
+// CreateUserAsAdmin provisions a new local account on behalf of an instance admin,
+// honouring the admin-only is_admin and skip_email_confirm fields and bypassing the
+// public-registration toggle. It commits s and returns the persisted user reloaded
+// so the status reflects what was actually stored.
+func CreateUserAsAdmin(s *xorm.Session, body *CreateUserBody) (*user.User, error) {
+ newUser, err := RegisterUser(s, &user.User{
+ Username: body.Username,
+ Password: body.Password,
+ Email: body.Email,
+ Name: body.Name,
+ Language: body.Language,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if body.IsAdmin {
+ if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil {
+ return nil, err
+ }
+ newUser.IsAdmin = true
+ }
+
+ // Force Active when the admin asked to skip, or when no mailer exists to send the confirmation.
+ if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() {
+ if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil {
+ return nil, err
+ }
+ newUser.Status = user.StatusActive
+ }
+
+ if err := s.Commit(); err != nil {
+ return nil, err
+ }
+
+ // Reload on a fresh session so the returned status reflects what was actually
+ // persisted (e.g. StatusEmailConfirmationRequired on mail-enabled instances).
+ rs := db.NewSession()
+ defer rs.Close()
+ return user.GetUserByID(rs, newUser.ID)
+}
diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go
new file mode 100644
index 000000000..eeae1c794
--- /dev/null
+++ b/pkg/routes/api/shared/admin_user.go
@@ -0,0 +1,66 @@
+// 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 shared holds route helpers used by both /api/v1 and /api/v2 so the two
+// versions render identical responses without one importing the other.
+package shared
+
+import (
+ "code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/user"
+)
+
+// AdminUser re-exposes fields hidden by the default user.User JSON view.
+type AdminUser struct {
+ *user.User
+ IsAdmin bool `json:"is_admin" readOnly:"true" doc:"Whether the user is an instance admin."`
+ Status user.Status `json:"status" readOnly:"true" doc:"Account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked)."`
+ Issuer string `json:"issuer" readOnly:"true" doc:"Authentication issuer; empty or 'local' for local accounts."`
+ Subject string `json:"subject,omitempty" readOnly:"true" doc:"External subject identifier, for non-local accounts."`
+ AuthProvider string `json:"auth_provider,omitempty" readOnly:"true" doc:"Resolved auth provider name (e.g. 'LDAP' or an OIDC provider), empty for local accounts."`
+}
+
+// NewAdminUser builds the admin-facing user view, resolving the auth-provider
+// display name from the configured OIDC providers.
+func NewAdminUser(u *user.User, providers []*openid.Provider) *AdminUser {
+ return &AdminUser{
+ 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
+}
diff --git a/pkg/routes/api/v1/admin/overview.go b/pkg/routes/api/v1/admin/overview.go
index 3911e31be..6c5b71858 100644
--- a/pkg/routes/api/v1/admin/overview.go
+++ b/pkg/routes/api/v1/admin/overview.go
@@ -20,77 +20,27 @@ import (
"net/http"
"code.vikunja.io/api/pkg/db"
- "code.vikunja.io/api/pkg/license"
+ "code.vikunja.io/api/pkg/models"
+
"github.com/labstack/echo/v5"
)
-type ShareCounts struct {
- LinkShares int64 `json:"link_shares"`
- TeamShares int64 `json:"team_shares"`
- UserShares int64 `json:"user_shares"`
-}
-
-type Overview struct {
- Users int64 `json:"users"`
- Projects int64 `json:"projects"`
- Tasks int64 `json:"tasks"`
- Teams int64 `json:"teams"`
- Shares ShareCounts `json:"shares"`
- License license.Info `json:"license"`
-}
-
// GetOverview returns aggregate instance counts and metadata.
// @Summary Admin overview
// @Description Returns per-instance counts (users, projects, shares) plus version and license info. Instance-admin only, gated by the admin_panel feature.
// @tags admin
// @Produce json
// @Security JWTKeyAuth
-// @Success 200 {object} admin.Overview
+// @Success 200 {object} models.Overview
// @Failure 404 {object} web.HTTPError
// @Router /admin/overview [get]
func GetOverview(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- users, err := s.Table("users").Count()
+ overview, err := models.BuildOverview(s)
if err != nil {
return err
}
- projects, err := s.Table("projects").Count()
- if err != nil {
- return err
- }
- tasks, err := s.Table("tasks").Count()
- if err != nil {
- return err
- }
- teams, err := s.Table("teams").Count()
- if err != nil {
- return err
- }
- linkShares, err := s.Table("link_shares").Count()
- if err != nil {
- return err
- }
- teamShares, err := s.Table("team_projects").Count()
- if err != nil {
- return err
- }
- userShares, err := s.Table("users_projects").Count()
- if err != nil {
- return err
- }
-
- return c.JSON(http.StatusOK, Overview{
- Users: users,
- Projects: projects,
- Tasks: tasks,
- Teams: teams,
- Shares: ShareCounts{
- LinkShares: linkShares,
- TeamShares: teamShares,
- UserShares: userShares,
- },
- License: license.CurrentInfo(),
- })
+ return c.JSON(http.StatusOK, overview)
}
diff --git a/pkg/routes/api/v1/admin/user_create.go b/pkg/routes/api/v1/admin/user_create.go
index 5ba455579..bedddef58 100644
--- a/pkg/routes/api/v1/admin/user_create.go
+++ b/pkg/routes/api/v1/admin/user_create.go
@@ -20,28 +20,14 @@ import (
"errors"
"net/http"
- "code.vikunja.io/api/pkg/config"
"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"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"github.com/labstack/echo/v5"
)
-// CreateUserBody wraps user.APIUserPassword with admin-only fields.
-type CreateUserBody struct {
- // The full name of the new user. Optional.
- Name string `json:"name"`
- // The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.
- Language string `json:"language" valid:"language"`
- user.APIUserPassword
- // Mark the new user as an instance admin.
- IsAdmin bool `json:"is_admin"`
- // Activate the new user immediately without email confirmation.
- SkipEmailConfirm bool `json:"skip_email_confirm"`
-}
-
// CreateUser provisions a new account on behalf of an instance admin.
// @Summary Create a user (admin)
// @Description Create a new local user account. Respects the admin-only fields `is_admin` and `skip_email_confirm`. The public registration toggle is bypassed.
@@ -49,12 +35,12 @@ type CreateUserBody struct {
// @Accept json
// @Produce json
// @Security JWTKeyAuth
-// @Param body body admin.CreateUserBody true "The user to create"
-// @Success 200 {object} admin.User
+// @Param body body models.CreateUserBody true "The user to create"
+// @Success 200 {object} shared.AdminUser
// @Failure 400 {object} web.HTTPError
// @Router /admin/users [post]
func CreateUser(c *echo.Context) error {
- body := &CreateUserBody{}
+ body := &models.CreateUserBody{}
if err := c.Bind(body); err != nil {
return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."})
}
@@ -69,52 +55,15 @@ func CreateUser(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
- newUser, err := models.RegisterUser(s, &user.User{
- Username: body.Username,
- Password: body.Password,
- Email: body.Email,
- Name: body.Name,
- Language: body.Language,
- })
+ newUser, err := models.CreateUserAsAdmin(s, body)
if err != nil {
_ = s.Rollback()
return err
}
- if body.IsAdmin {
- if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil {
- _ = s.Rollback()
- return err
- }
- newUser.IsAdmin = true
- }
-
- // Force Active when the admin asked to skip, or when no mailer exists to send the confirmation.
- if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() {
- if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil {
- _ = s.Rollback()
- return err
- }
- newUser.Status = user.StatusActive
- }
-
- if err := s.Commit(); err != nil {
- _ = s.Rollback()
- return err
- }
-
- // Reload the user so the returned status reflects what was actually persisted
- // (e.g. StatusEmailConfirmationRequired on mail-enabled instances).
- rs := db.NewSession()
- defer rs.Close()
- newUser, err = user.GetUserByID(rs, newUser.ID)
- if err != nil {
- return err
- }
-
providers, err := openid.GetAllProviders()
if err != nil {
return err
}
- return c.JSON(http.StatusOK, newAdminUser(newUser, providers))
+ return c.JSON(http.StatusOK, shared.NewAdminUser(newUser, providers))
}
diff --git a/pkg/routes/api/v1/admin/users.go b/pkg/routes/api/v1/admin/users.go
index f9392b772..5117b9e46 100644
--- a/pkg/routes/api/v1/admin/users.go
+++ b/pkg/routes/api/v1/admin/users.go
@@ -18,52 +18,13 @@ package admin
import (
"code.vikunja.io/api/pkg/modules/auth/openid"
+ "code.vikunja.io/api/pkg/routes/api/shared"
"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:"-"`
@@ -79,7 +40,7 @@ type UserList struct {
// @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
+// @Success 200 {array} shared.AdminUser
// @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) {
@@ -106,9 +67,9 @@ func (*UserList) ReadAll(s *xorm.Session, _ web.Auth, search string, page, perPa
return nil, 0, 0, err
}
- out := make([]*User, 0, len(users))
+ out := make([]*shared.AdminUser, 0, len(users))
for _, u := range users {
- out = append(out, newAdminUser(u, providers))
+ out = append(out, shared.NewAdminUser(u, providers))
}
return out, len(out), totalCount, nil
}
diff --git a/pkg/routes/api/v1/admin/users_admin.go b/pkg/routes/api/v1/admin/users_admin.go
index 5f31b269f..30b25917c 100644
--- a/pkg/routes/api/v1/admin/users_admin.go
+++ b/pkg/routes/api/v1/admin/users_admin.go
@@ -23,7 +23,9 @@ import (
"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/routes/api/shared"
"code.vikunja.io/api/pkg/user"
+
"github.com/labstack/echo/v5"
)
@@ -41,7 +43,7 @@ type IsAdminPatch struct {
// @Security JWTKeyAuth
// @Param id path int true "User ID"
// @Param body body admin.IsAdminPatch true "New admin value"
-// @Success 200 {object} admin.User
+// @Success 200 {object} shared.AdminUser
// @Failure 400 {object} web.HTTPError
// @Failure 404 {object} web.HTTPError
// @Router /admin/users/{id}/admin [patch]
@@ -92,5 +94,5 @@ func PatchAdmin(c *echo.Context) error {
if err != nil {
return err
}
- return c.JSON(http.StatusOK, newAdminUser(target, providers))
+ return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers))
}
diff --git a/pkg/routes/api/v1/admin/users_mgmt.go b/pkg/routes/api/v1/admin/users_mgmt.go
index 2e95d88b5..489df40e8 100644
--- a/pkg/routes/api/v1/admin/users_mgmt.go
+++ b/pkg/routes/api/v1/admin/users_mgmt.go
@@ -23,7 +23,9 @@ import (
"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/routes/api/shared"
"code.vikunja.io/api/pkg/user"
+
"github.com/labstack/echo/v5"
)
@@ -41,7 +43,7 @@ type StatusPatch struct {
// @Security JWTKeyAuth
// @Param id path int true "User ID"
// @Param body body admin.StatusPatch true "Status"
-// @Success 200 {object} admin.User
+// @Success 200 {object} shared.AdminUser
// @Failure 400 {object} web.HTTPError
// @Failure 404 {object} web.HTTPError
// @Router /admin/users/{id}/status [patch]
@@ -96,7 +98,7 @@ func PatchStatus(c *echo.Context) error {
if err != nil {
return err
}
- return c.JSON(http.StatusOK, newAdminUser(target, providers))
+ return c.JSON(http.StatusOK, shared.NewAdminUser(target, providers))
}
// DeleteUser removes a user either immediately or through the self-deletion flow.