mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-02-16 19:35:14 +00:00
Add users UI for admins (#1634)
Co-authored-by: Lauris BH <lauris@nix.lv>
This commit is contained in:
parent
fa5b0fb96e
commit
277a839157
14 changed files with 266 additions and 33 deletions
|
@ -108,6 +108,7 @@ func HandleAuth(c *gin.Context) {
|
|||
u.Secret = tmpuser.Secret
|
||||
u.Email = tmpuser.Email
|
||||
u.Avatar = tmpuser.Avatar
|
||||
u.Admin = u.Admin || config.IsAdmin(tmpuser)
|
||||
|
||||
// if self-registration is enabled for whitelisted organizations we need to
|
||||
// check the user's organization membership.
|
||||
|
|
|
@ -58,7 +58,12 @@ func PatchUser(c *gin.Context) {
|
|||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
user.Active = in.Active
|
||||
|
||||
// TODO: allow to change login (currently used as primary key)
|
||||
// TODO: disallow to change login, email, avatar if the user is using oauth
|
||||
user.Email = in.Email
|
||||
user.Avatar = in.Avatar
|
||||
user.Admin = in.Admin
|
||||
|
||||
err = _store.UpdateUser(user)
|
||||
if err != nil {
|
||||
|
@ -77,7 +82,6 @@ func PostUser(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
user := &model.User{
|
||||
Active: true,
|
||||
Login: in.Login,
|
||||
Email: in.Email,
|
||||
Avatar: in.Avatar,
|
||||
|
|
|
@ -56,23 +56,17 @@ type User struct {
|
|||
// the avatar url for this user.
|
||||
Avatar string `json:"avatar_url" xorm:" varchar(500) 'user_avatar'"`
|
||||
|
||||
// Activate indicates the user is active in the system.
|
||||
Active bool `json:"active" xorm:"user_active"`
|
||||
|
||||
// Synced is the timestamp when the user was synced with the forge.
|
||||
Synced int64 `json:"synced" xorm:"user_synced"`
|
||||
|
||||
// Admin indicates the user is a system administrator.
|
||||
//
|
||||
// NOTE: This is sourced from the WOODPECKER_ADMINS environment variable and is no
|
||||
// longer persisted in the database.
|
||||
Admin bool `json:"admin,omitempty" xorm:"-"`
|
||||
// NOTE: If the username is part of the WOODPECKER_ADMINS
|
||||
// environment variable this value will be set to true on login.
|
||||
Admin bool `json:"admin,omitempty" xorm:"user_admin"`
|
||||
|
||||
// Hash is a unique token used to sign tokens.
|
||||
Hash string `json:"-" xorm:"UNIQUE varchar(500) 'user_hash'"`
|
||||
|
||||
// DEPRECATED Admin indicates the user is a system administrator.
|
||||
XAdmin bool `json:"-" xorm:"user_admin"`
|
||||
}
|
||||
|
||||
// TableName return database table name for xorm
|
||||
|
|
|
@ -48,10 +48,6 @@ func SetUser() gin.HandlerFunc {
|
|||
return user.Hash, err
|
||||
})
|
||||
if err == nil {
|
||||
confv := c.MustGet("config")
|
||||
if conf, ok := confv.(*model.Settings); ok {
|
||||
user.Admin = conf.IsAdmin(user)
|
||||
}
|
||||
c.Set("user", user)
|
||||
|
||||
// if this is a session token (ie not the API token)
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright 2022 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 migration
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
var removeActiveFromUsers = task{
|
||||
name: "remove-active-from-users",
|
||||
required: true,
|
||||
fn: func(sess *xorm.Session) error {
|
||||
return dropTableColumns(sess, "users", "user_active")
|
||||
},
|
||||
}
|
|
@ -25,7 +25,7 @@ import (
|
|||
)
|
||||
|
||||
// APPEND NEW MIGRATIONS
|
||||
// they are executed in order and if one fail woodpecker try to rollback and quit
|
||||
// they are executed in order and if one fails woodpecker will try to rollback and quits
|
||||
var migrationTasks = []*task{
|
||||
&legacy2Xorm,
|
||||
&alterTableReposDropFallback,
|
||||
|
@ -42,6 +42,7 @@ var migrationTasks = []*task{
|
|||
&renameTableProcsToSteps,
|
||||
&renameRemoteToForge,
|
||||
&renameForgeIDToForgeRemoteID,
|
||||
&removeActiveFromUsers,
|
||||
}
|
||||
|
||||
var allBeans = []interface{}{
|
||||
|
|
|
@ -75,7 +75,6 @@ func TestUsers(t *testing.T) {
|
|||
Secret: "976f22a5eef7caacb7e678d6c52f49b1",
|
||||
Email: "foo@bar.com",
|
||||
Avatar: "b9015b0857e16ac4d94a0ffd9a0b79c8",
|
||||
Active: true,
|
||||
}
|
||||
|
||||
g.Assert(store.CreateUser(user)).IsNil()
|
||||
|
@ -87,7 +86,6 @@ func TestUsers(t *testing.T) {
|
|||
g.Assert(user.Secret).Equal(getuser.Secret)
|
||||
g.Assert(user.Email).Equal(getuser.Email)
|
||||
g.Assert(user.Avatar).Equal(getuser.Avatar)
|
||||
g.Assert(user.Active).Equal(getuser.Active)
|
||||
})
|
||||
|
||||
g.It("Should Get a User By Login", func() {
|
||||
|
|
4
web/components.d.ts
vendored
4
web/components.d.ts
vendored
|
@ -10,12 +10,15 @@ 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']
|
||||
AdminQueueTab: typeof import('./src/components/admin/settings/AdminQueueTab.vue')['default']
|
||||
AdminSecretsTab: typeof import('./src/components/admin/settings/AdminSecretsTab.vue')['default']
|
||||
AdminUsersTab: typeof import('./src/components/admin/settings/AdminUsersTab.vue')['default']
|
||||
Badge: typeof import('./src/components/atomic/Badge.vue')['default']
|
||||
BadgeTab: typeof import('./src/components/repo/settings/BadgeTab.vue')['default']
|
||||
Button: typeof import('./src/components/atomic/Button.vue')['default']
|
||||
Checkbox: typeof import('./src/components/form/Checkbox.vue')['default']
|
||||
CheckboxesField: typeof import('./src/components/form/CheckboxesField.vue')['default']
|
||||
copy: typeof import('./src/components/admin/settings/AdminAgentsTab copy.vue')['default']
|
||||
CronTab: typeof import('./src/components/repo/settings/CronTab.vue')['default']
|
||||
DeployPipelinePopup: typeof import('./src/components/layout/popups/DeployPipelinePopup.vue')['default']
|
||||
DocsLink: typeof import('./src/components/atomic/DocsLink.vue')['default']
|
||||
|
@ -42,6 +45,7 @@ declare module '@vue/runtime-core' {
|
|||
IIcBaselineFileDownload: typeof import('~icons/ic/baseline-file-download')['default']
|
||||
IIcBaselineFileDownloadOff: typeof import('~icons/ic/baseline-file-download-off')['default']
|
||||
IIcBaselineHealing: typeof import('~icons/ic/baseline-healing')['default']
|
||||
IIcBaselinePause: typeof import('~icons/ic/baseline-pause')['default']
|
||||
IIcBaselinePlayArrow: typeof import('~icons/ic/baseline-play-arrow')['default']
|
||||
IIconoirArrowLeft: typeof import('~icons/iconoir/arrow-left')['default']
|
||||
IIconParkOutlineAlarmClock: typeof import('~icons/icon-park-outline/alarm-clock')['default']
|
||||
|
|
|
@ -362,6 +362,28 @@
|
|||
"last_contact": "Last contact",
|
||||
"never": "Never",
|
||||
"delete_confirm": "Do you really want to delete this agent? It wont be able to connected to the server anymore."
|
||||
},
|
||||
"users": {
|
||||
"users": "Users",
|
||||
"desc": "Users registered for this server",
|
||||
"login": "Login",
|
||||
"email": "Email",
|
||||
"avatar_url": "Avatar URL",
|
||||
"save": "Save user",
|
||||
"cancel": "Cancel",
|
||||
"show":"Show users",
|
||||
"add":"Add user",
|
||||
"none":"There are no users yet.",
|
||||
"delete_confirm": "Do you really want to delete this user?",
|
||||
"deleted": "User deleted",
|
||||
"created": "User created",
|
||||
"saved": "User saved",
|
||||
"admin": {
|
||||
"admin": "Admin",
|
||||
"placeholder": "User is an admin"
|
||||
},
|
||||
"delete_user": "Delete user",
|
||||
"edit_user": "Edit user"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
161
web/src/components/admin/settings/AdminUsersTab.vue
Normal file
161
web/src/components/admin/settings/AdminUsersTab.vue
Normal file
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<Panel>
|
||||
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-gray-600">
|
||||
<div class="ml-2">
|
||||
<h1 class="text-xl text-color">{{ $t('admin.settings.users.users') }}</h1>
|
||||
<p class="text-sm text-color-alt">{{ $t('admin.settings.users.desc') }}</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="selectedUser"
|
||||
class="ml-auto"
|
||||
:text="$t('admin.settings.users.show')"
|
||||
start-icon="back"
|
||||
@click="selectedUser = undefined"
|
||||
/>
|
||||
<Button v-else class="ml-auto" :text="$t('admin.settings.users.add')" start-icon="plus" @click="showAddUser" />
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedUser" class="space-y-4 text-color">
|
||||
<ListItem v-for="user in users" :key="user.id" class="items-center gap-2">
|
||||
<img v-if="user.avatar_url" class="rounded-md h-6" :src="user.avatar_url" />
|
||||
<span>{{ user.login }}</span>
|
||||
<Badge
|
||||
v-if="user.admin"
|
||||
class="ml-auto hidden md:inline-block"
|
||||
:label="$t('admin.settings.users.admin.admin')"
|
||||
/>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
:title="$t('admin.settings.users.edit_user')"
|
||||
class="ml-2 w-8 h-8"
|
||||
@click="editUser(user)"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
:title="$t('admin.settings.users.delete_user')"
|
||||
class="ml-2 w-8 h-8 hover:text-red-400 hover:dark:text-red-500"
|
||||
:is-loading="isDeleting"
|
||||
@click="deleteUser(user)"
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<div v-if="users?.length === 0" class="ml-2">{{ $t('admin.settings.users.none') }}</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<form @submit.prevent="saveUser">
|
||||
<InputField :label="$t('admin.settings.users.login')">
|
||||
<TextField v-model="selectedUser.login" :disabled="isEditingUser" />
|
||||
</InputField>
|
||||
|
||||
<InputField :label="$t('admin.settings.users.email')">
|
||||
<TextField v-model="selectedUser.email" />
|
||||
</InputField>
|
||||
|
||||
<InputField :label="$t('admin.settings.users.avatar_url')">
|
||||
<div class="flex gap-2">
|
||||
<img v-if="selectedUser.avatar_url" class="rounded-md h-8 w-8" :src="selectedUser.avatar_url" />
|
||||
<TextField v-model="selectedUser.avatar_url" />
|
||||
</div>
|
||||
</InputField>
|
||||
|
||||
<InputField :label="$t('admin.settings.users.admin.admin')">
|
||||
<Checkbox
|
||||
:model-value="selectedUser.admin || false"
|
||||
:label="$t('admin.settings.users.admin.placeholder')"
|
||||
@update:model-value="selectedUser!.admin = $event"
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button :text="$t('admin.settings.users.cancel')" @click="selectedUser = undefined" />
|
||||
|
||||
<Button
|
||||
:is-loading="isSaving"
|
||||
type="submit"
|
||||
color="green"
|
||||
:text="isEditingUser ? $t('admin.settings.users.save') : $t('admin.settings.users.add')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Badge from '~/components/atomic/Badge.vue';
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import IconButton from '~/components/atomic/IconButton.vue';
|
||||
import ListItem from '~/components/atomic/ListItem.vue';
|
||||
import InputField from '~/components/form/InputField.vue';
|
||||
import TextField from '~/components/form/TextField.vue';
|
||||
import Panel from '~/components/layout/Panel.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { useAsyncAction } from '~/compositions/useAsyncAction';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { User } from '~/lib/api/types';
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const { t } = useI18n();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const selectedUser = ref<Partial<User>>();
|
||||
const isEditingUser = computed(() => !!selectedUser.value?.id);
|
||||
|
||||
async function loadUsers() {
|
||||
users.value = await apiClient.getUsers();
|
||||
}
|
||||
|
||||
const { doSubmit: saveUser, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!selectedUser.value) {
|
||||
throw new Error("Unexpected: Can't get user");
|
||||
}
|
||||
|
||||
if (isEditingUser.value) {
|
||||
await apiClient.updateUser(selectedUser.value);
|
||||
selectedUser.value = undefined;
|
||||
} else {
|
||||
selectedUser.value = await apiClient.createUser(selectedUser.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: t(isEditingUser.value ? 'admin.settings.users.saved' : 'admin.settings.users.created'),
|
||||
type: 'success',
|
||||
});
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteUser, isLoading: isDeleting } = useAsyncAction(async (_user: User) => {
|
||||
// eslint-disable-next-line no-restricted-globals, no-alert
|
||||
if (!confirm(t('admin.settings.users.delete_confirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await apiClient.deleteUser(_user);
|
||||
notifications.notify({ title: t('admin.settings.users.deleted'), type: 'success' });
|
||||
await loadUsers();
|
||||
});
|
||||
|
||||
function editUser(user: User) {
|
||||
selectedUser.value = cloneDeep(user);
|
||||
}
|
||||
|
||||
function showAddUser() {
|
||||
selectedUser.value = cloneDeep({ login: '' });
|
||||
}
|
||||
|
||||
const reloadInterval = ref<number>();
|
||||
onMounted(async () => {
|
||||
await loadUsers();
|
||||
reloadInterval.value = window.setInterval(loadUsers, 5000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reloadInterval.value) {
|
||||
window.clearInterval(reloadInterval.value);
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -2,23 +2,21 @@
|
|||
<span class="text-xs font-medium inline-flex">
|
||||
<span
|
||||
class="pl-2 pr-1 py-0.5 bg-gray-800 text-gray-200 dark:bg-gray-600 border-2 border-gray-800 dark:border-gray-600 rounded-l-full"
|
||||
:class="{
|
||||
'rounded-r-full pr-2': !value,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="pl-1 pr-2 py-0.5 border-2 border-gray-800 dark:border-gray-600 rounded-r-full">
|
||||
<span v-if="value" class="pl-1 pr-2 py-0.5 border-2 border-gray-800 dark:border-gray-600 rounded-r-full">
|
||||
{{ value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRef } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
value?: string | number;
|
||||
}>();
|
||||
|
||||
const label = toRef(props, 'label');
|
||||
const value = toRef(props, 'value');
|
||||
</script>
|
||||
|
|
|
@ -24,13 +24,15 @@
|
|||
<CheckboxesField v-model="innerValue.event" :options="secretEventsOptions" />
|
||||
</InputField>
|
||||
|
||||
<Button type="button" color="gray" :text="$t('cancel')" @click="$emit('cancel')" />
|
||||
<Button
|
||||
type="submit"
|
||||
color="green"
|
||||
:is-loading="isSaving"
|
||||
:text="isEditingSecret ? $t(i18nPrefix + 'save') : $t(i18nPrefix + 'add')"
|
||||
/>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<Button type="button" color="gray" :text="$t('cancel')" @click="$emit('cancel')" />
|
||||
<Button
|
||||
type="submit"
|
||||
color="green"
|
||||
:is-loading="isSaving"
|
||||
:text="isEditingSecret ? $t(i18nPrefix + 'save') : $t(i18nPrefix + 'add')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
RepoPermissions,
|
||||
RepoSettings,
|
||||
Secret,
|
||||
User,
|
||||
} from './types';
|
||||
|
||||
type RepoListOptions = {
|
||||
|
@ -250,6 +251,26 @@ export default class WoodpeckerClient extends ApiClient {
|
|||
return this._delete(`/api/agents/${agent.id}`);
|
||||
}
|
||||
|
||||
getUsers(): Promise<User[]> {
|
||||
return this._get('/api/users') as Promise<User[]>;
|
||||
}
|
||||
|
||||
getUser(username: string): Promise<User> {
|
||||
return this._get(`/api/users/${username}`) as Promise<User>;
|
||||
}
|
||||
|
||||
createUser(user: Partial<User>): Promise<User> {
|
||||
return this._post('/api/users', user) as Promise<User>;
|
||||
}
|
||||
|
||||
updateUser(user: Partial<User>): Promise<unknown> {
|
||||
return this._patch(`/api/users/${user.login}`, user);
|
||||
}
|
||||
|
||||
deleteUser(user: User): Promise<unknown> {
|
||||
return this._delete(`/api/users/${user.login}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line promise/prefer-await-to-callbacks
|
||||
on(callback: (data: { pipeline?: Pipeline; repo?: Repo; step?: PipelineStep }) => void): EventSource {
|
||||
return this._subscribe('/stream/events', callback, {
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
<Tab id="secrets" :title="$t('admin.settings.secrets.secrets')">
|
||||
<AdminSecretsTab />
|
||||
</Tab>
|
||||
<Tab id="users" :title="$t('admin.settings.users.users')">
|
||||
<AdminUsersTab />
|
||||
</Tab>
|
||||
<Tab id="agents" :title="$t('admin.settings.agents.agents')">
|
||||
<AdminAgentsTab />
|
||||
</Tab>
|
||||
|
@ -19,6 +22,7 @@ import { useRouter } from 'vue-router';
|
|||
|
||||
import AdminAgentsTab from '~/components/admin/settings/AdminAgentsTab.vue';
|
||||
import AdminSecretsTab from '~/components/admin/settings/AdminSecretsTab.vue';
|
||||
import AdminUsersTab from '~/components/admin/settings/AdminUsersTab.vue';
|
||||
import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
|
||||
import Tab from '~/components/layout/scaffold/Tab.vue';
|
||||
import useAuthentication from '~/compositions/useAuthentication';
|
||||
|
|
Loading…
Reference in a new issue