From e25f997281a1b7ff3aeb9b303507af052513f6a7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:41:39 +0200 Subject: [PATCH] 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. --- pkg/models/admin_overview.go | 83 ++++++++++++++++++++++++++ pkg/models/admin_user_create.go | 80 +++++++++++++++++++++++++ pkg/routes/api/shared/admin_user.go | 66 ++++++++++++++++++++ pkg/routes/api/v1/admin/overview.go | 60 ++----------------- pkg/routes/api/v1/admin/user_create.go | 63 ++----------------- pkg/routes/api/v1/admin/users.go | 47 ++------------- pkg/routes/api/v1/admin/users_admin.go | 6 +- pkg/routes/api/v1/admin/users_mgmt.go | 6 +- 8 files changed, 252 insertions(+), 159 deletions(-) create mode 100644 pkg/models/admin_overview.go create mode 100644 pkg/models/admin_user_create.go create mode 100644 pkg/routes/api/shared/admin_user.go 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.