refactor(admin): extract shared admin overview, user-create and user-view helpers

Move the admin overview computation and struct into models.BuildOverview /
models.Overview, the admin create-user flow into models.CreateUserAsAdmin /
models.CreateUserBody, and the admin user response view into a new
pkg/routes/api/shared package (shared.AdminUser / shared.NewAdminUser) so both
the v1 and v2 admin routes call the same code. The v1 handlers are refactored
onto these helpers and stay byte-identical on the wire.
This commit is contained in:
kolaente 2026-06-11 09:41:39 +02:00 committed by kolaente
parent 9c3c1047ac
commit e25f997281
8 changed files with 252 additions and 159 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
// 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
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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.