diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go index 6155f23ee..931d86b4a 100644 --- a/cmd/server/docs/docs.go +++ b/cmd/server/docs/docs.go @@ -819,6 +819,90 @@ const docTemplate = `{ } } }, + "/orgs": { + "get": { + "description": "Returns all registered orgs in the system. Requires admin rights.", + "produces": [ + "application/json" + ], + "tags": [ + "Orgs" + ], + "summary": "Get all orgs", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "for response pagination, page offset number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 50, + "description": "for response pagination, max items per page", + "name": "perPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Org" + } + } + } + } + } + }, + "/orgs/{id}": { + "delete": { + "description": "Deletes the given org. Requires admin rights.", + "produces": [ + "application/json" + ], + "tags": [ + "Orgs" + ], + "summary": "Delete an org", + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cpersonal access token\u003e", + "description": "Insert your personal access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "the org's id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/Org" + } + } + } + } + }, "/orgs/{org_id}": { "get": { "produces": [ diff --git a/server/api/orgs.go b/server/api/orgs.go new file mode 100644 index 000000000..2e830acf2 --- /dev/null +++ b/server/api/orgs.go @@ -0,0 +1,72 @@ +// Copyright 2023 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/woodpecker-ci/woodpecker/server/router/middleware/session" + "github.com/woodpecker-ci/woodpecker/server/store" +) + +// GetOrgs +// +// @Summary Get all orgs +// @Description Returns all registered orgs in the system. Requires admin rights. +// @Router /orgs [get] +// @Produce json +// @Success 200 {array} Org +// @Tags Orgs +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param page query int false "for response pagination, page offset number" default(1) +// @Param perPage query int false "for response pagination, max items per page" default(50) +func GetOrgs(c *gin.Context) { + orgs, err := store.FromContext(c).OrgList(session.Pagination(c)) + if err != nil { + c.String(500, "Error getting user list. %s", err) + return + } + c.JSON(200, orgs) +} + +// DeleteOrg +// +// @Summary Delete an org +// @Description Deletes the given org. Requires admin rights. +// @Router /orgs/{id} [delete] +// @Produce json +// @Success 204 {object} Org +// @Tags Orgs +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param id path string true "the org's id" +func DeleteOrg(c *gin.Context) { + _store := store.FromContext(c) + + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + c.String(http.StatusBadRequest, "Error parsing org id. %s", err) + return + } + + err = _store.OrgDelete(orgID) + if err != nil { + c.String(http.StatusInternalServerError, "Error deleting org %d. %s", orgID, err) + } + + c.String(http.StatusNoContent, "") +} diff --git a/server/forge/mocks/forge.go b/server/forge/mocks/forge.go index 2a24e3905..7f158f0df 100644 --- a/server/forge/mocks/forge.go +++ b/server/forge/mocks/forge.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.32.3. DO NOT EDIT. +// Code generated by mockery v2.33.0. DO NOT EDIT. package mocks diff --git a/server/router/api.go b/server/router/api.go index e22dc3b8c..53025bde1 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -46,20 +46,25 @@ func apiRoutes(e *gin.RouterGroup) { users.DELETE("/:login", api.DeleteUser) } - apiBase.GET("/orgs/lookup/*org_full_name", api.LookupOrg) - orgBase := apiBase.Group("/orgs/:org_id") + orgs := apiBase.Group("/orgs") { - orgBase.GET("/permissions", api.GetOrgPermissions) - - org := orgBase.Group("") + orgs.GET("", session.MustAdmin(), api.GetOrgs) + orgs.GET("/lookup/*org_full_name", api.LookupOrg) + orgBase := orgs.Group("/:org_id") { - org.Use(session.MustOrgMember(true)) - org.GET("", api.GetOrg) - org.GET("/secrets", api.GetOrgSecretList) - org.POST("/secrets", api.PostOrgSecret) - org.GET("/secrets/:secret", api.GetOrgSecret) - org.PATCH("/secrets/:secret", api.PatchOrgSecret) - org.DELETE("/secrets/:secret", api.DeleteOrgSecret) + orgBase.GET("/permissions", api.GetOrgPermissions) + + org := orgBase.Group("") + { + org.Use(session.MustOrgMember(true)) + org.DELETE("", session.MustAdmin(), api.DeleteOrg) + org.GET("", api.GetOrg) + org.GET("/secrets", api.GetOrgSecretList) + org.POST("/secrets", api.PostOrgSecret) + org.GET("/secrets/:secret", api.GetOrgSecret) + org.PATCH("/secrets/:secret", api.PatchOrgSecret) + org.DELETE("/secrets/:secret", api.DeleteOrgSecret) + } } } diff --git a/server/store/datastore/org.go b/server/store/datastore/org.go index f8c7673b4..5bd8f1c3c 100644 --- a/server/store/datastore/org.go +++ b/server/store/datastore/org.go @@ -17,8 +17,9 @@ package datastore import ( "strings" - "github.com/woodpecker-ci/woodpecker/server/model" "xorm.io/xorm" + + "github.com/woodpecker-ci/woodpecker/server/model" ) func (s storage) OrgCreate(org *model.Org) error { @@ -62,3 +63,8 @@ func (s storage) OrgRepoList(org *model.Org, p *model.ListOptions) ([]*model.Rep var repos []*model.Repo return repos, s.paginate(p).OrderBy("repo_id").Where("repo_org_id = ?", org.ID).Find(&repos) } + +func (s storage) OrgList(p *model.ListOptions) ([]*model.Org, error) { + var orgs []*model.Org + return orgs, s.paginate(p).Where("is_user = ?", false).OrderBy("id").Find(&orgs) +} diff --git a/server/store/mocks/store.go b/server/store/mocks/store.go index ea9f02314..2015f6e9d 100644 --- a/server/store/mocks/store.go +++ b/server/store/mocks/store.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.32.3. DO NOT EDIT. +// Code generated by mockery v2.33.0. DO NOT EDIT. package mocks @@ -1249,6 +1249,32 @@ func (_m *Store) OrgGet(_a0 int64) (*model.Org, error) { return r0, r1 } +// OrgList provides a mock function with given fields: _a0 +func (_m *Store) OrgList(_a0 *model.ListOptions) ([]*model.Org, error) { + ret := _m.Called(_a0) + + var r0 []*model.Org + var r1 error + if rf, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Org, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(*model.ListOptions) []*model.Org); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Org) + } + } + + if rf, ok := ret.Get(1).(func(*model.ListOptions) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // OrgRepoList provides a mock function with given fields: _a0, _a1 func (_m *Store) OrgRepoList(_a0 *model.Org, _a1 *model.ListOptions) ([]*model.Repo, error) { ret := _m.Called(_a0, _a1) diff --git a/server/store/store.go b/server/store/store.go index 1d59601af..1774aa1c3 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -189,6 +189,7 @@ type Store interface { OrgFindByName(string) (*model.Org, error) OrgUpdate(*model.Org) error OrgDelete(int64) error + OrgList(*model.ListOptions) ([]*model.Org, error) // Org repos OrgRepoList(*model.Org, *model.ListOptions) ([]*model.Repo, error) diff --git a/web/components.d.ts b/web/components.d.ts index 38d4ab200..1d54a87d5 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -12,6 +12,7 @@ declare module '@vue/runtime-core' { ActionsTab: typeof import('./src/components/repo/settings/ActionsTab.vue')['default'] ActivePipelines: typeof import('./src/components/layout/header/ActivePipelines.vue')['default'] AdminAgentsTab: typeof import('./src/components/admin/settings/AdminAgentsTab.vue')['default'] + AdminOrgsTab: typeof import('./src/components/admin/settings/AdminOrgsTab.vue')['default'] AdminQueueStats: typeof import('./src/components/admin/settings/queue/AdminQueueStats.vue')['default'] AdminQueueTab: typeof import('./src/components/admin/settings/AdminQueueTab.vue')['default'] AdminSecretsTab: typeof import('./src/components/admin/settings/AdminSecretsTab.vue')['default'] diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 0ada0bbac..e07b88c14 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -421,6 +421,16 @@ }, "delete_user": "Delete user", "edit_user": "Edit user" + }, + "orgs": { + "orgs": "Organizations", + "desc": "Organizations owning repositories on this server", + "none": "There are no organizations yet.", + "org_settings": "Organization settings", + "delete_org": "Delete organization", + "deleted": "Organization deleted", + "delete_confirm": "Do you really want to delete this organization?", + "view": "View organization" } } }, diff --git a/web/src/components/admin/settings/AdminOrgsTab.vue b/web/src/components/admin/settings/AdminOrgsTab.vue new file mode 100644 index 000000000..6d54b8585 --- /dev/null +++ b/web/src/components/admin/settings/AdminOrgsTab.vue @@ -0,0 +1,68 @@ + + + diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 68f03fdc0..a95faad57 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -303,6 +303,14 @@ export default class WoodpeckerClient extends ApiClient { return this._delete('/api/user/token') as Promise; } + getOrgs(page: number): Promise { + return this._get(`/api/orgs?page=${page}`) as Promise; + } + + deleteOrg(org: Org): Promise { + return this._delete(`/api/orgs/${org.id}`); + } + // eslint-disable-next-line promise/prefer-await-to-callbacks on(callback: (data: { pipeline?: Pipeline; repo?: Repo; step?: PipelineWorkflow }) => void): EventSource { return this._subscribe('/api/stream/events', callback, { diff --git a/web/src/views/admin/AdminSettings.vue b/web/src/views/admin/AdminSettings.vue index 07c9f228d..84f6ec92d 100644 --- a/web/src/views/admin/AdminSettings.vue +++ b/web/src/views/admin/AdminSettings.vue @@ -9,6 +9,9 @@ + + + @@ -24,6 +27,7 @@ import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import AdminAgentsTab from '~/components/admin/settings/AdminAgentsTab.vue'; +import AdminOrgsTab from '~/components/admin/settings/AdminOrgsTab.vue'; import AdminQueueTab from '~/components/admin/settings/AdminQueueTab.vue'; import AdminSecretsTab from '~/components/admin/settings/AdminSecretsTab.vue'; import AdminUsersTab from '~/components/admin/settings/AdminUsersTab.vue';