Add users UI for admins (#1634)

Co-authored-by: Lauris BH <lauris@nix.lv>
This commit is contained in:
Anbraten 2023-03-18 21:21:20 +01:00 committed by GitHub
parent fa5b0fb96e
commit 277a839157
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 266 additions and 33 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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