mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-10-22 11:28:08 +00:00
Add editing of secrets and registries (#823)
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"fuse.js": "6.4.6",
|
"fuse.js": "6.4.6",
|
||||||
"humanize-duration": "3.27.0",
|
"humanize-duration": "3.27.0",
|
||||||
"javascript-time-ago": "2.3.10",
|
"javascript-time-ago": "2.3.10",
|
||||||
|
"lodash": "4.17.21",
|
||||||
"node-emoji": "1.11.0",
|
"node-emoji": "1.11.0",
|
||||||
"pinia": "2.0.0",
|
"pinia": "2.0.0",
|
||||||
"vue": "v3.2.20",
|
"vue": "v3.2.20",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@iconify/json": "1.1.421",
|
"@iconify/json": "1.1.421",
|
||||||
"@types/humanize-duration": "3.27.0",
|
"@types/humanize-duration": "3.27.0",
|
||||||
"@types/javascript-time-ago": "2.0.3",
|
"@types/javascript-time-ago": "2.0.3",
|
||||||
|
"@types/lodash": "4.14.179",
|
||||||
"@types/node": "16.11.6",
|
"@types/node": "16.11.6",
|
||||||
"@types/node-emoji": "1.8.1",
|
"@types/node-emoji": "1.8.1",
|
||||||
"@typescript-eslint/eslint-plugin": "5.6.0",
|
"@typescript-eslint/eslint-plugin": "5.6.0",
|
||||||
|
@@ -32,9 +32,9 @@
|
|||||||
@click="doClick"
|
@click="doClick"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<Icon v-if="startIcon" :name="startIcon" class="mr-1" :class="{ invisible: isLoading }" />
|
<Icon v-if="startIcon" :name="startIcon" class="mr-1 !w-6 !h-6" :class="{ invisible: isLoading }" />
|
||||||
<span :class="{ invisible: isLoading }">{{ text }}</span>
|
<span :class="{ invisible: isLoading }">{{ text }}</span>
|
||||||
<Icon v-if="endIcon" :name="endIcon" class="ml-2" :class="{ invisible: isLoading }" />
|
<Icon v-if="endIcon" :name="endIcon" class="ml-2 w-6 h-6" :class="{ invisible: isLoading }" />
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-0 right-0 bottom-0 flex items-center justify-center"
|
class="absolute left-0 top-0 right-0 bottom-0 flex items-center justify-center"
|
||||||
:class="{
|
:class="{
|
||||||
|
@@ -33,6 +33,7 @@
|
|||||||
<i-bx-bx-power-off v-else-if="name === 'turn-off'" class="h-6 w-6" />
|
<i-bx-bx-power-off v-else-if="name === 'turn-off'" class="h-6 w-6" />
|
||||||
<i-mdi-chevron-right v-else-if="name === 'chevron-right'" class="h-6 w-6" />
|
<i-mdi-chevron-right v-else-if="name === 'chevron-right'" class="h-6 w-6" />
|
||||||
<i-carbon-close-outline v-else-if="name === 'close'" class="h-6 w-6" />
|
<i-carbon-close-outline v-else-if="name === 'close'" class="h-6 w-6" />
|
||||||
|
<i-ic-baseline-edit v-else-if="name === 'edit'" class="h-6 w-6" />
|
||||||
<div v-else-if="name === 'blank'" class="h-6 w-6" />
|
<div v-else-if="name === 'blank'" class="h-6 w-6" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -74,7 +75,8 @@ export type IconNames =
|
|||||||
| 'heal'
|
| 'heal'
|
||||||
| 'chevron-right'
|
| 'chevron-right'
|
||||||
| 'turn-off'
|
| 'turn-off'
|
||||||
| 'close';
|
| 'close'
|
||||||
|
| 'edit';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Icon',
|
name: 'Icon',
|
||||||
|
@@ -22,6 +22,7 @@
|
|||||||
focus:outline-none focus:border-blue-400
|
focus:outline-none focus:border-blue-400
|
||||||
dark:placeholder-gray-600 dark:text-gray-500
|
dark:placeholder-gray-600 dark:text-gray-500
|
||||||
"
|
"
|
||||||
|
:disabled="disabled"
|
||||||
:type="type"
|
:type="type"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
/>
|
/>
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
focus:outline-none focus:border-blue-400
|
focus:outline-none focus:border-blue-400
|
||||||
dark:placeholder-gray-600 dark:text-gray-500
|
dark:placeholder-gray-600 dark:text-gray-500
|
||||||
"
|
"
|
||||||
|
:disabled="disabled"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:rows="lines"
|
:rows="lines"
|
||||||
/>
|
/>
|
||||||
@@ -70,6 +72,10 @@ export default defineComponent({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 1,
|
default: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: {
|
emits: {
|
||||||
|
@@ -9,21 +9,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="showAddRegistry"
|
v-if="selectedRegistry"
|
||||||
class="ml-auto"
|
class="ml-auto"
|
||||||
start-icon="list"
|
start-icon="back"
|
||||||
text="Show registries"
|
text="Show registries"
|
||||||
@click="showAddRegistry = false"
|
@click="selectedRegistry = undefined"
|
||||||
/>
|
/>
|
||||||
<Button v-else class="ml-auto" start-icon="plus" text="Add registry" @click="showAddRegistry = true" />
|
<Button v-else class="ml-auto" start-icon="plus" text="Add registry" @click="selectedRegistry = {}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!showAddRegistry" class="space-y-4 text-gray-500">
|
<div v-if="!selectedRegistry" class="space-y-4 text-gray-500">
|
||||||
<ListItem v-for="registry in registries" :key="registry.id" class="items-center">
|
<ListItem v-for="registry in registries" :key="registry.id" class="items-center">
|
||||||
<span>{{ registry.address }}</span>
|
<span>{{ registry.address }}</span>
|
||||||
|
<IconButton icon="edit" class="ml-auto w-8 h-8" @click="selectedRegistry = registry" />
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="trash"
|
icon="trash"
|
||||||
class="ml-auto w-8 h-8 hover:text-red-400"
|
class="w-8 h-8 hover:text-red-400"
|
||||||
:is-loading="isDeleting"
|
:is-loading="isDeleting"
|
||||||
@click="deleteRegistry(registry)"
|
@click="deleteRegistry(registry)"
|
||||||
/>
|
/>
|
||||||
@@ -36,7 +37,12 @@
|
|||||||
<form @submit.prevent="createRegistry">
|
<form @submit.prevent="createRegistry">
|
||||||
<InputField label="Address">
|
<InputField label="Address">
|
||||||
<!-- TODO: check input field Address is a valid address -->
|
<!-- TODO: check input field Address is a valid address -->
|
||||||
<TextField v-model="selectedRegistry.address" placeholder="Registry Address (e.g. docker.io)" required />
|
<TextField
|
||||||
|
v-model="selectedRegistry.address"
|
||||||
|
placeholder="Registry Address (e.g. docker.io)"
|
||||||
|
required
|
||||||
|
:disabled="isEditingRegistry"
|
||||||
|
/>
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<InputField label="Username">
|
<InputField label="Username">
|
||||||
@@ -47,14 +53,14 @@
|
|||||||
<TextField v-model="selectedRegistry.password" placeholder="Password" required />
|
<TextField v-model="selectedRegistry.password" placeholder="Password" required />
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<Button type="submit" :is-loading="isSaving" text="Add registry" />
|
<Button type="submit" :is-loading="isSaving" :text="isEditingRegistry ? 'Save registy' : 'Add registry'" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, inject, onMounted, Ref, ref } from 'vue';
|
import { computed, defineComponent, inject, onMounted, Ref, ref } from 'vue';
|
||||||
|
|
||||||
import Button from '~/components/atomic/Button.vue';
|
import Button from '~/components/atomic/Button.vue';
|
||||||
import DocsLink from '~/components/atomic/DocsLink.vue';
|
import DocsLink from '~/components/atomic/DocsLink.vue';
|
||||||
@@ -88,8 +94,8 @@ export default defineComponent({
|
|||||||
|
|
||||||
const repo = inject<Ref<Repo>>('repo');
|
const repo = inject<Ref<Repo>>('repo');
|
||||||
const registries = ref<Registry[]>();
|
const registries = ref<Registry[]>();
|
||||||
const showAddRegistry = ref(false);
|
const selectedRegistry = ref<Partial<Registry>>();
|
||||||
const selectedRegistry = ref<Partial<Registry>>({});
|
const isEditingRegistry = computed(() => !!selectedRegistry.value?.id);
|
||||||
|
|
||||||
async function loadRegistries() {
|
async function loadRegistries() {
|
||||||
if (!repo?.value) {
|
if (!repo?.value) {
|
||||||
@@ -104,10 +110,17 @@ export default defineComponent({
|
|||||||
throw new Error("Unexpected: Can't load repo");
|
throw new Error("Unexpected: Can't load repo");
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.createRegistry(repo.value.owner, repo.value.name, selectedRegistry.value);
|
if (!selectedRegistry.value) {
|
||||||
|
throw new Error("Unexpected: Can't get registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditingRegistry.value) {
|
||||||
|
await apiClient.updateRegistry(repo.value.owner, repo.value.name, selectedRegistry.value);
|
||||||
|
} else {
|
||||||
|
await apiClient.createRegistry(repo.value.owner, repo.value.name, selectedRegistry.value);
|
||||||
|
}
|
||||||
notifications.notify({ title: 'Registry credentials created', type: 'success' });
|
notifications.notify({ title: 'Registry credentials created', type: 'success' });
|
||||||
showAddRegistry.value = false;
|
selectedRegistry.value = undefined;
|
||||||
selectedRegistry.value = {};
|
|
||||||
await loadRegistries();
|
await loadRegistries();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +139,7 @@ export default defineComponent({
|
|||||||
await loadRegistries();
|
await loadRegistries();
|
||||||
});
|
});
|
||||||
|
|
||||||
return { selectedRegistry, registries, showAddRegistry, isSaving, isDeleting, createRegistry, deleteRegistry };
|
return { selectedRegistry, registries, isEditingRegistry, isSaving, isDeleting, createRegistry, deleteRegistry };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@@ -9,16 +9,16 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="showAddSecret"
|
v-if="selectedSecret"
|
||||||
class="ml-auto"
|
class="ml-auto"
|
||||||
text="Show secrets"
|
text="Show secrets"
|
||||||
start-icon="list"
|
start-icon="back"
|
||||||
@click="showAddSecret = false"
|
@click="selectedSecret = undefined"
|
||||||
/>
|
/>
|
||||||
<Button v-else class="ml-auto" text="Add secret" start-icon="plus" @click="showAddSecret = true" />
|
<Button v-else class="ml-auto" text="Add secret" start-icon="plus" @click="showAddSecret" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!showAddSecret" class="space-y-4 text-gray-500">
|
<div v-if="!selectedSecret" class="space-y-4 text-gray-500">
|
||||||
<ListItem v-for="secret in secrets" :key="secret.id" class="items-center">
|
<ListItem v-for="secret in secrets" :key="secret.id" class="items-center">
|
||||||
<span>{{ secret.name }}</span>
|
<span>{{ secret.name }}</span>
|
||||||
<div class="ml-auto">
|
<div class="ml-auto">
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
>{{ event }}</span
|
>{{ event }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<IconButton icon="edit" class="ml-2 w-8 h-8" @click="selectedSecret = secret" />
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="trash"
|
icon="trash"
|
||||||
class="ml-2 w-8 h-8 hover:text-red-400"
|
class="ml-2 w-8 h-8 hover:text-red-400"
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<form @submit.prevent="createSecret">
|
<form @submit.prevent="createSecret">
|
||||||
<InputField label="Name">
|
<InputField label="Name">
|
||||||
<TextField v-model="selectedSecret.name" placeholder="Name" required />
|
<TextField v-model="selectedSecret.name" placeholder="Name" required :disabled="isEditingSecret" />
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<InputField label="Value">
|
<InputField label="Value">
|
||||||
@@ -61,14 +62,15 @@
|
|||||||
<CheckboxesField v-model="selectedSecret.event" :options="secretEventsOptions" />
|
<CheckboxesField v-model="selectedSecret.event" :options="secretEventsOptions" />
|
||||||
</InputField>
|
</InputField>
|
||||||
|
|
||||||
<Button :is-loading="isSaving" type="submit" text="Add secret" />
|
<Button :is-loading="isSaving" type="submit" :text="isEditingSecret ? 'Save secret' : 'Add secret'" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, inject, onMounted, Ref, ref } from 'vue';
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { computed, defineComponent, inject, onMounted, Ref, ref } from 'vue';
|
||||||
|
|
||||||
import Button from '~/components/atomic/Button.vue';
|
import Button from '~/components/atomic/Button.vue';
|
||||||
import DocsLink from '~/components/atomic/DocsLink.vue';
|
import DocsLink from '~/components/atomic/DocsLink.vue';
|
||||||
@@ -123,9 +125,18 @@ export default defineComponent({
|
|||||||
|
|
||||||
const repo = inject<Ref<Repo>>('repo');
|
const repo = inject<Ref<Repo>>('repo');
|
||||||
const secrets = ref<Secret[]>();
|
const secrets = ref<Secret[]>();
|
||||||
const showAddSecret = ref(false);
|
const selectedSecret = ref<Partial<Secret>>();
|
||||||
const selectedSecret = ref<Partial<Secret>>({ ...emptySecret });
|
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
||||||
const images = ref('');
|
const images = computed<string>({
|
||||||
|
get() {
|
||||||
|
return selectedSecret.value?.image?.join(',') || '';
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (selectedSecret.value) {
|
||||||
|
selectedSecret.value.image = value.split(',').map((s) => s.trim());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function loadSecrets() {
|
async function loadSecrets() {
|
||||||
if (!repo?.value) {
|
if (!repo?.value) {
|
||||||
@@ -140,12 +151,17 @@ export default defineComponent({
|
|||||||
throw new Error("Unexpected: Can't load repo");
|
throw new Error("Unexpected: Can't load repo");
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageList = images.value.split(',').map((s) => s.trim());
|
if (!selectedSecret.value) {
|
||||||
selectedSecret.value.image = imageList.filter((s) => s !== '');
|
throw new Error("Unexpected: Can't get secret");
|
||||||
await apiClient.createSecret(repo.value.owner, repo.value.name, selectedSecret.value);
|
}
|
||||||
|
|
||||||
|
if (isEditingSecret.value) {
|
||||||
|
await apiClient.updateSecret(repo.value.owner, repo.value.name, selectedSecret.value);
|
||||||
|
} else {
|
||||||
|
await apiClient.createSecret(repo.value.owner, repo.value.name, selectedSecret.value);
|
||||||
|
}
|
||||||
notifications.notify({ title: 'Secret created', type: 'success' });
|
notifications.notify({ title: 'Secret created', type: 'success' });
|
||||||
showAddSecret.value = false;
|
selectedSecret.value = undefined;
|
||||||
selectedSecret.value = { ...emptySecret };
|
|
||||||
await loadSecrets();
|
await loadSecrets();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,6 +175,10 @@ export default defineComponent({
|
|||||||
await loadSecrets();
|
await loadSecrets();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function showAddSecret() {
|
||||||
|
selectedSecret.value = cloneDeep(emptySecret);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSecrets();
|
await loadSecrets();
|
||||||
});
|
});
|
||||||
@@ -168,9 +188,10 @@ export default defineComponent({
|
|||||||
selectedSecret,
|
selectedSecret,
|
||||||
secrets,
|
secrets,
|
||||||
images,
|
images,
|
||||||
showAddSecret,
|
isEditingSecret,
|
||||||
isSaving,
|
isSaving,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
|
showAddSecret,
|
||||||
createSecret,
|
createSecret,
|
||||||
deleteSecret,
|
deleteSecret,
|
||||||
};
|
};
|
||||||
|
@@ -111,6 +111,10 @@ export default class WoodpeckerClient extends ApiClient {
|
|||||||
return this._post(`/api/repos/${owner}/${repo}/secrets`, secret);
|
return this._post(`/api/repos/${owner}/${repo}/secrets`, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSecret(owner: string, repo: string, secret: Partial<Secret>): Promise<unknown> {
|
||||||
|
return this._patch(`/api/repos/${owner}/${repo}/secrets/${secret.name}`, secret);
|
||||||
|
}
|
||||||
|
|
||||||
deleteSecret(owner: string, repo: string, secretName: string): Promise<unknown> {
|
deleteSecret(owner: string, repo: string, secretName: string): Promise<unknown> {
|
||||||
return this._delete(`/api/repos/${owner}/${repo}/secrets/${secretName}`);
|
return this._delete(`/api/repos/${owner}/${repo}/secrets/${secretName}`);
|
||||||
}
|
}
|
||||||
@@ -123,6 +127,10 @@ export default class WoodpeckerClient extends ApiClient {
|
|||||||
return this._post(`/api/repos/${owner}/${repo}/registry`, registry);
|
return this._post(`/api/repos/${owner}/${repo}/registry`, registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateRegistry(owner: string, repo: string, registry: Partial<Registry>): Promise<unknown> {
|
||||||
|
return this._patch(`/api/repos/${owner}/${repo}/registry/${registry.address}`, registry);
|
||||||
|
}
|
||||||
|
|
||||||
deleteRegistry(owner: string, repo: string, registryAddress: string): Promise<unknown> {
|
deleteRegistry(owner: string, repo: string, registryAddress: string): Promise<unknown> {
|
||||||
return this._delete(`/api/repos/${owner}/${repo}/registry/${registryAddress}`);
|
return this._delete(`/api/repos/${owner}/${repo}/registry/${registryAddress}`);
|
||||||
}
|
}
|
||||||
|
@@ -200,6 +200,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||||
|
|
||||||
|
"@types/lodash@4.14.179":
|
||||||
|
version "4.14.179"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.179.tgz#490ec3288088c91295780237d2497a3aa9dfb5c5"
|
||||||
|
integrity sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w==
|
||||||
|
|
||||||
"@types/node-emoji@1.8.1":
|
"@types/node-emoji@1.8.1":
|
||||||
version "1.8.1"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node-emoji/-/node-emoji-1.8.1.tgz#689cb74fdf6e84309bcafce93a135dfecd01de3f"
|
resolved "https://registry.yarnpkg.com/@types/node-emoji/-/node-emoji-1.8.1.tgz#689cb74fdf6e84309bcafce93a135dfecd01de3f"
|
||||||
@@ -1958,7 +1963,7 @@ lodash.truncate@^4.4.2:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
|
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
|
||||||
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
|
integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
|
||||||
|
|
||||||
lodash@^4.17.19, lodash@^4.17.21:
|
lodash@4.17.21, lodash@^4.17.19, lodash@^4.17.21:
|
||||||
version "4.17.21"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
Reference in New Issue
Block a user