mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2024-12-29 20:00:30 +00:00
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:
parent
15bd20d58b
commit
479ced3b25
12 changed files with 300 additions and 15 deletions
|
@ -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
72
server/api/orgs.go
Normal 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, "")
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
1
web/components.d.ts
vendored
|
@ -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']
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
68
web/src/components/admin/settings/AdminOrgsTab.vue
Normal file
68
web/src/components/admin/settings/AdminOrgsTab.vue
Normal 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>
|
|
@ -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, {
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue