mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-03-28 23:25:32 +00:00
Show secrets from org and global level (#2873)
Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
parent
60a3922e02
commit
16803d6217
13 changed files with 191 additions and 94 deletions
|
@ -4233,6 +4233,12 @@ const docTemplate = `{
|
|||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"org_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"repo_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
@ -70,8 +70,8 @@ type SecretStore interface {
|
|||
// Secret represents a secret variable, such as a password or token.
|
||||
type Secret struct {
|
||||
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
|
||||
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
|
||||
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
|
||||
OrgID int64 `json:"org_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
|
||||
RepoID int64 `json:"repo_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
|
||||
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
|
||||
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
|
||||
Images []string `json:"images" xorm:"json 'secret_images'"`
|
||||
|
@ -89,15 +89,20 @@ func (s *Secret) BeforeInsert() {
|
|||
}
|
||||
|
||||
// Global secret.
|
||||
func (s Secret) Global() bool {
|
||||
func (s Secret) IsGlobal() bool {
|
||||
return s.RepoID == 0 && s.OrgID == 0
|
||||
}
|
||||
|
||||
// Organization secret.
|
||||
func (s Secret) Organization() bool {
|
||||
func (s Secret) IsOrganization() bool {
|
||||
return s.RepoID == 0 && s.OrgID != 0
|
||||
}
|
||||
|
||||
// Repository secret.
|
||||
func (s Secret) IsRepository() bool {
|
||||
return s.RepoID != 0 && s.OrgID == 0
|
||||
}
|
||||
|
||||
// Match returns true if an image and event match the restricted list.
|
||||
func (s *Secret) Match(event WebhookEvent) bool {
|
||||
if len(s.Events) == 0 {
|
||||
|
|
|
@ -48,16 +48,17 @@ func (b *builtin) SecretListPipeline(repo *model.Repo, _ *model.Pipeline, p *mod
|
|||
// Priority order in case of duplicate names are repository, user/organization, global
|
||||
secrets := make([]*model.Secret, 0, len(s))
|
||||
uniq := make(map[string]struct{})
|
||||
for _, cond := range []struct {
|
||||
Global bool
|
||||
Organization bool
|
||||
for _, condition := range []struct {
|
||||
IsRepository bool
|
||||
IsOrganization bool
|
||||
IsGlobal bool
|
||||
}{
|
||||
{},
|
||||
{Organization: true},
|
||||
{Global: true},
|
||||
{IsRepository: true},
|
||||
{IsOrganization: true},
|
||||
{IsGlobal: true},
|
||||
} {
|
||||
for _, secret := range s {
|
||||
if secret.Global() != cond.Global || secret.Organization() != cond.Organization {
|
||||
if secret.IsRepository() != condition.IsRepository || secret.IsOrganization() != condition.IsOrganization || secret.IsGlobal() != condition.IsGlobal {
|
||||
continue
|
||||
}
|
||||
if _, ok := uniq[secret.Name]; ok {
|
||||
|
|
|
@ -51,7 +51,7 @@ func TestSecretListPipeline(t *testing.T) {
|
|||
// repo secret
|
||||
repoSecret := &model.Secret{
|
||||
ID: 3,
|
||||
OrgID: 1,
|
||||
OrgID: 0,
|
||||
RepoID: 1,
|
||||
Name: "secret",
|
||||
Value: "value-repo",
|
||||
|
|
|
@ -253,7 +253,7 @@ func TestOrgSecretList(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
|
||||
assert.True(t, list[0].Organization())
|
||||
assert.True(t, list[0].IsOrganization())
|
||||
}
|
||||
|
||||
func TestGlobalSecretFind(t *testing.T) {
|
||||
|
@ -306,5 +306,5 @@ func TestGlobalSecretList(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
|
||||
assert.True(t, list[0].Global())
|
||||
assert.True(t, list[0].IsGlobal())
|
||||
}
|
||||
|
|
|
@ -140,7 +140,7 @@
|
|||
"saved": "Secret saved",
|
||||
"images": {
|
||||
"images": "Available for following images",
|
||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
||||
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||
},
|
||||
"events": {
|
||||
"events": "Available at following events",
|
||||
|
@ -305,7 +305,7 @@
|
|||
"saved": "Organization secret saved",
|
||||
"images": {
|
||||
"images": "Available for following images",
|
||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
||||
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||
},
|
||||
"plugins_only": "Only available for plugins",
|
||||
"events": {
|
||||
|
@ -334,7 +334,7 @@
|
|||
"saved": "Global secret saved",
|
||||
"images": {
|
||||
"images": "Available for following images",
|
||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
||||
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||
},
|
||||
"plugins_only": "Only available for plugins",
|
||||
"events": {
|
||||
|
@ -476,7 +476,7 @@
|
|||
"saved": "User secret saved",
|
||||
"images": {
|
||||
"images": "Available for following images",
|
||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
||||
"desc": "List of images where this secret is available, leave empty to allow all images"
|
||||
},
|
||||
"plugins_only": "Only available for plugins",
|
||||
"events": {
|
||||
|
@ -504,5 +504,7 @@
|
|||
"default": "default",
|
||||
"info": "Info",
|
||||
"running_version": "You are running Woodpecker {0}",
|
||||
"update_woodpecker": "Please update your Woodpecker instance to {0}"
|
||||
"update_woodpecker": "Please update your Woodpecker instance to {0}",
|
||||
"global_level_secret": "global secret",
|
||||
"org_level_secret": "organization secret"
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
>
|
||||
<slot>
|
||||
<Icon v-if="startIcon" :name="startIcon" class="!w-6 !h-6" :class="{ invisible: isLoading, 'mr-1': text }" />
|
||||
<span :class="{ invisible: isLoading }">{{ text }}</span>
|
||||
<span :class="{ invisible: isLoading }" class="flex-shrink-0">{{ text }}</span>
|
||||
<Icon v-if="endIcon" :name="endIcon" class="ml-2 w-6 h-6" :class="{ invisible: isLoading }" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
<SecretList
|
||||
v-if="!selectedSecret"
|
||||
v-model="secrets"
|
||||
:model-value="secrets"
|
||||
i18n-prefix="repo.settings.secrets."
|
||||
:is-deleting="isDeleting"
|
||||
@edit="editSecret"
|
||||
|
@ -64,15 +64,54 @@ const repo = inject<Ref<Repo>>('repo');
|
|||
const selectedSecret = ref<Partial<Secret>>();
|
||||
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
||||
|
||||
async function loadSecrets(page: number): Promise<Secret[] | null> {
|
||||
async function loadSecrets(page: number, level: 'repo' | 'org' | 'global'): Promise<Secret[] | null> {
|
||||
if (!repo?.value) {
|
||||
throw new Error("Unexpected: Can't load repo");
|
||||
}
|
||||
|
||||
return apiClient.getSecretList(repo.value.id, page);
|
||||
switch (level) {
|
||||
case 'repo':
|
||||
return apiClient.getSecretList(repo.value.id, page);
|
||||
case 'org':
|
||||
return apiClient.getOrgSecretList(repo.value.org_id, page);
|
||||
case 'global':
|
||||
return apiClient.getGlobalSecretList(page);
|
||||
default:
|
||||
throw new Error(`Unexpected level: ${level}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
|
||||
const { resetPage, data: _secrets } = usePagination(loadSecrets, () => !selectedSecret.value, {
|
||||
each: ['repo', 'org', 'global'],
|
||||
});
|
||||
const secrets = computed(() => {
|
||||
const secretsList: Record<string, Secret & { edit?: boolean; level: 'repo' | 'org' | 'global' }> = {};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const level of ['repo', 'org', 'global']) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const secret of _secrets.value) {
|
||||
if (
|
||||
((level === 'repo' && secret.repo_id !== 0 && secret.org_id === 0) ||
|
||||
(level === 'org' && secret.repo_id === 0 && secret.org_id !== 0) ||
|
||||
(level === 'global' && secret.repo_id === 0 && secret.org_id === 0)) &&
|
||||
!secretsList[secret.name]
|
||||
) {
|
||||
secretsList[secret.name] = { ...secret, edit: secret.repo_id !== 0, level };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const levelsOrder = {
|
||||
global: 0,
|
||||
org: 1,
|
||||
repo: 2,
|
||||
};
|
||||
|
||||
return Object.values(secretsList)
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.toSorted((a, b) => levelsOrder[b.level] - levelsOrder[a.level]);
|
||||
});
|
||||
|
||||
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!repo?.value) {
|
||||
|
@ -93,7 +132,7 @@ const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async ()
|
|||
type: 'success',
|
||||
});
|
||||
selectedSecret.value = undefined;
|
||||
resetPage();
|
||||
await resetPage();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
|
||||
|
@ -103,7 +142,7 @@ const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (
|
|||
|
||||
await apiClient.deleteSecret(repo.value.id, _secret.name);
|
||||
notifications.notify({ title: i18n.t('repo.settings.secrets.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
await resetPage();
|
||||
});
|
||||
|
||||
function editSecret(secret: Secret) {
|
||||
|
|
|
@ -11,11 +11,27 @@
|
|||
</InputField>
|
||||
|
||||
<InputField :label="$t(i18nPrefix + 'value')">
|
||||
<TextField v-model="innerValue.value" :placeholder="$t(i18nPrefix + 'value')" :lines="5" />
|
||||
<TextField
|
||||
v-model="innerValue.value"
|
||||
:placeholder="$t(i18nPrefix + 'value')"
|
||||
:lines="5"
|
||||
:required="!isEditingSecret"
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<InputField :label="$t(i18nPrefix + 'images.images')">
|
||||
<TextField v-model="images" :placeholder="$t(i18nPrefix + 'images.desc')" />
|
||||
<span class="ml-1 mb-2 text-wp-text-alt-100">{{ $t(i18nPrefix + 'images.desc') }}</span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="image in innerValue.images" :key="image" class="flex gap-2">
|
||||
<TextField :model-value="image" disabled />
|
||||
<Button type="button" color="gray" start-icon="trash" @click="removeImage(image)" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<TextField v-model="newImage" @keydown.enter.prevent="addNewImage" />
|
||||
<Button type="button" color="gray" start-icon="plus" @click="addNewImage" />
|
||||
</div>
|
||||
</div>
|
||||
</InputField>
|
||||
|
||||
<InputField :label="$t(i18nPrefix + 'events.events')">
|
||||
|
@ -36,7 +52,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { computed, ref, toRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
|
@ -67,21 +83,20 @@ const innerValue = computed({
|
|||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const images = computed<string>({
|
||||
get() {
|
||||
return innerValue.value?.images?.join(',') || '';
|
||||
},
|
||||
set(value) {
|
||||
if (innerValue.value) {
|
||||
innerValue.value.images = value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
}
|
||||
},
|
||||
});
|
||||
const isEditingSecret = computed(() => !!innerValue.value?.id);
|
||||
|
||||
const newImage = ref('');
|
||||
function addNewImage() {
|
||||
if (!newImage.value) {
|
||||
return;
|
||||
}
|
||||
innerValue.value.images?.push(newImage.value);
|
||||
newImage.value = '';
|
||||
}
|
||||
function removeImage(image: string) {
|
||||
innerValue.value.images = innerValue.value.images?.filter((i) => i !== image);
|
||||
}
|
||||
|
||||
const secretEventsOptions: CheckboxOption[] = [
|
||||
{ value: WebhookEvents.Push, text: i18n.t('repo.pipeline.event.push') },
|
||||
{ value: WebhookEvents.Tag, text: i18n.t('repo.pipeline.event.tag') },
|
||||
|
@ -99,6 +114,11 @@ function save() {
|
|||
if (!innerValue.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newImage.value) {
|
||||
innerValue.value.images?.push(newImage.value);
|
||||
}
|
||||
|
||||
emit('save', innerValue.value);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -6,22 +6,29 @@
|
|||
class="items-center !bg-wp-background-200 !dark:bg-wp-background-100"
|
||||
>
|
||||
<span>{{ secret.name }}</span>
|
||||
<Badge
|
||||
v-if="secret.edit === false"
|
||||
class="ml-2"
|
||||
:label="secret.org_id === 0 ? $t('global_level_secret') : $t('org_level_secret')"
|
||||
/>
|
||||
<div class="ml-auto space-x-2 <md:hidden">
|
||||
<Badge v-for="event in secret.events" :key="event" :label="event" />
|
||||
</div>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
class="ml-2 <md:ml-auto w-8 h-8"
|
||||
:title="$t('repo.settings.secrets.edit')"
|
||||
@click="editSecret(secret)"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
|
||||
:is-loading="isDeleting"
|
||||
:title="$t('repo.settings.secrets.delete')"
|
||||
@click="deleteSecret(secret)"
|
||||
/>
|
||||
<template v-if="secret.edit !== false">
|
||||
<IconButton
|
||||
icon="edit"
|
||||
class="ml-2 <md:ml-auto w-8 h-8"
|
||||
:title="$t('repo.settings.secrets.edit')"
|
||||
@click="editSecret(secret)"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
|
||||
:is-loading="isDeleting"
|
||||
:title="$t('repo.settings.secrets.delete')"
|
||||
@click="deleteSecret(secret)"
|
||||
/>
|
||||
</template>
|
||||
</ListItem>
|
||||
|
||||
<div v-if="secrets?.length === 0" class="ml-2">{{ $t(i18nPrefix + 'none') }}</div>
|
||||
|
@ -32,12 +39,13 @@
|
|||
import { toRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Badge from '~/components/atomic/Badge.vue';
|
||||
import IconButton from '~/components/atomic/IconButton.vue';
|
||||
import ListItem from '~/components/atomic/ListItem.vue';
|
||||
import { Secret } from '~/lib/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Secret[];
|
||||
modelValue: (Secret & { edit?: boolean })[];
|
||||
isDeleting: boolean;
|
||||
i18nPrefix: string;
|
||||
}>();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
import { onMounted, Ref, ref, watch } from 'vue';
|
||||
import { onMounted, Ref, ref, UnwrapRef, watch } from 'vue';
|
||||
|
||||
export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>): Promise<T[]> {
|
||||
let hasMore = true;
|
||||
|
@ -15,59 +15,71 @@ export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>):
|
|||
return result;
|
||||
}
|
||||
|
||||
export function usePagination<T>(
|
||||
_loadData: (page: number) => Promise<T[] | null>,
|
||||
export function usePagination<T, S = unknown>(
|
||||
_loadData: (page: number, arg: S) => Promise<T[] | null>,
|
||||
isActive: () => boolean = () => true,
|
||||
scrollElement = ref(document.getElementById('scroll-component')),
|
||||
{ scrollElement: _scrollElement, each: _each }: { scrollElement?: Ref<HTMLElement | null>; each?: S[] } = {},
|
||||
) {
|
||||
const scrollElement = _scrollElement ?? ref(document.getElementById('scroll-component'));
|
||||
const page = ref(1);
|
||||
const pageSize = ref(0);
|
||||
const hasMore = ref(true);
|
||||
const data = ref<T[]>([]) as Ref<T[]>;
|
||||
const loading = ref(false);
|
||||
const each = ref(_each ?? []);
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
const newData = await _loadData(page.value);
|
||||
hasMore.value = newData !== null && newData.length >= pageSize.value;
|
||||
if (newData !== null && newData.length !== 0) {
|
||||
if (page.value === 1) {
|
||||
pageSize.value = newData.length;
|
||||
data.value = newData;
|
||||
} else {
|
||||
data.value.push(...newData);
|
||||
}
|
||||
} else if (page.value === 1) {
|
||||
data.value = [];
|
||||
} else {
|
||||
hasMore.value = false;
|
||||
if (loading.value === true || hasMore.value === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const newData = (await _loadData(page.value, each.value?.[0] as S)) ?? [];
|
||||
hasMore.value = newData.length >= pageSize.value && newData.length > 0;
|
||||
if (newData.length > 0) {
|
||||
data.value.push(...newData);
|
||||
}
|
||||
|
||||
// last page and each has more
|
||||
if (!hasMore.value && each.value.length > 0) {
|
||||
// use next each element
|
||||
each.value.shift();
|
||||
page.value = 1;
|
||||
pageSize.value = 0;
|
||||
hasMore.value = each.value.length > 0;
|
||||
if (hasMore.value) {
|
||||
loading.value = false;
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
pageSize.value = newData.length;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
watch(page, loadData);
|
||||
|
||||
useInfiniteScroll(
|
||||
scrollElement,
|
||||
() => {
|
||||
if (isActive() && !loading.value && hasMore.value) {
|
||||
// load more
|
||||
page.value += 1;
|
||||
}
|
||||
},
|
||||
{ distance: 10 },
|
||||
);
|
||||
|
||||
const resetPage = () => {
|
||||
if (page.value !== 1) {
|
||||
// just set page = 1, will be handled by watcher
|
||||
page.value = 1;
|
||||
} else {
|
||||
// we need to reload, but page is already 1, so changing won't trigger watcher
|
||||
loadData();
|
||||
function nextPage() {
|
||||
if (isActive() && !loading.value && hasMore.value) {
|
||||
page.value += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { resetPage, data };
|
||||
useInfiniteScroll(scrollElement, nextPage, { distance: 10 });
|
||||
|
||||
async function resetPage() {
|
||||
const _page = page.value;
|
||||
|
||||
hasMore.value = true;
|
||||
data.value = [];
|
||||
each.value = (_each ?? []) as UnwrapRef<S[]>;
|
||||
page.value = 1;
|
||||
|
||||
if (_page === 1) {
|
||||
// we need to reload manually as the page is already 1, so changing won't trigger watcher
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
return { resetPage, nextPage, data, hasMore, loading };
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { WebhookEvents } from './webhook';
|
|||
|
||||
export type Secret = {
|
||||
id: string;
|
||||
repo_id: number;
|
||||
org_id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
events: WebhookEvents[];
|
||||
|
|
|
@ -141,6 +141,8 @@ type (
|
|||
// Secret represents a secret variable, such as a password or token.
|
||||
Secret struct {
|
||||
ID int64 `json:"id"`
|
||||
OrgID int64 `json:"org_id"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Images []string `json:"images"`
|
||||
|
|
Loading…
Reference in a new issue