Add org list (#2338)

![Screenshot 2023-08-28 at 10-08-20
Woodpecker](https://github.com/woodpecker-ci/woodpecker/assets/80460567/e3248b05-7899-43ca-a0cf-4834eae078d8)
Closes #2307
This commit is contained in:
qwerty287 2023-08-28 11:15:16 +02:00 committed by GitHub
parent 15bd20d58b
commit 479ced3b25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 300 additions and 15 deletions

View file

@ -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": [

72
server/api/orgs.go Normal file
View file

@ -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 <personal access token>)
// @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 <personal access token>)
// @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, "")
}

View file

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

View file

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

View file

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

View file

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

View file

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

1
web/components.d.ts vendored
View file

@ -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']

View file

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

View file

@ -0,0 +1,68 @@
<template>
<Settings :title="$t('admin.settings.orgs.orgs')" :desc="$t('admin.settings.orgs.desc')">
<div class="space-y-4 text-wp-text-100">
<ListItem
v-for="org in orgs"
:key="org.id"
class="items-center gap-2 !bg-wp-background-200 !dark:bg-wp-background-100"
>
<span>{{ org.name }}</span>
<IconButton
icon="chevron-right"
:title="$t('admin.settings.orgs.view')"
class="ml-auto w-8 h-8"
:to="{ name: 'org', params: { orgId: org.id } }"
/>
<IconButton
icon="settings"
:title="$t('admin.settings.orgs.org_settings')"
class="w-8 h-8"
:to="{ name: 'org-settings', params: { orgId: org.id } }"
/>
<IconButton
icon="trash"
:title="$t('admin.settings.orgs.delete_org')"
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
:is-loading="isDeleting"
@click="deleteOrg(org)"
/>
</ListItem>
<div v-if="orgs?.length === 0" class="ml-2">{{ $t('admin.settings.orgs.none') }}</div>
</div>
</Settings>
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Settings from '~/components/layout/Settings.vue';
import useApiClient from '~/compositions/useApiClient';
import { useAsyncAction } from '~/compositions/useAsyncAction';
import useNotifications from '~/compositions/useNotifications';
import { usePagination } from '~/compositions/usePaginate';
import { Org } from '~/lib/api/types';
const apiClient = useApiClient();
const notifications = useNotifications();
const { t } = useI18n();
async function loadOrgs(page: number): Promise<Org[] | null> {
return apiClient.getOrgs(page);
}
const { resetPage, data: orgs } = usePagination(loadOrgs);
const { doSubmit: deleteOrg, isLoading: isDeleting } = useAsyncAction(async (_org: Org) => {
// eslint-disable-next-line no-restricted-globals, no-alert
if (!confirm(t('admin.settings.orgs.delete_confirm'))) {
return;
}
await apiClient.deleteOrg(_org);
notifications.notify({ title: t('admin.settings.orgs.deleted'), type: 'success' });
resetPage();
});
</script>

View file

@ -303,6 +303,14 @@ export default class WoodpeckerClient extends ApiClient {
return this._delete('/api/user/token') as Promise<string>;
}
getOrgs(page: number): Promise<Org[] | null> {
return this._get(`/api/orgs?page=${page}`) as Promise<Org[] | null>;
}
deleteOrg(org: Org): Promise<unknown> {
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, {

View file

@ -9,6 +9,9 @@
<Tab id="users" :title="$t('admin.settings.users.users')">
<AdminUsersTab />
</Tab>
<Tab id="orgs" :title="$t('admin.settings.orgs.orgs')">
<AdminOrgsTab />
</Tab>
<Tab id="agents" :title="$t('admin.settings.agents.agents')">
<AdminAgentsTab />
</Tab>
@ -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';