mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-30 16:28:23 -05:00
feat: add user-level webhooks settings page
Add a new settings page for managing user-level webhooks. Extract webhook form into shared WebhookManager component used by both project and user webhook settings. Add routing, translations, and navigation entry.
This commit is contained in:
277
frontend/src/components/misc/WebhookManager.vue
Normal file
277
frontend/src/components/misc/WebhookManager.vue
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {ref, watch} from 'vue'
|
||||||
|
|
||||||
|
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||||
|
import WebhookModel from '@/models/webhook'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
|
import FormField from '@/components/input/FormField.vue'
|
||||||
|
import Expandable from '@/components/base/Expandable.vue'
|
||||||
|
import User from '@/components/misc/User.vue'
|
||||||
|
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||||
|
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
webhooks: IWebhook[]
|
||||||
|
availableEvents: string[]
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
create: [webhook: IWebhook]
|
||||||
|
delete: [webhookId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineOptions({name: 'WebhookManager'})
|
||||||
|
|
||||||
|
const showNewForm = ref(false)
|
||||||
|
const showBasicAuth = ref(false)
|
||||||
|
const newWebhook = ref(new WebhookModel())
|
||||||
|
const newWebhookEvents = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function initEvents(events: string[]) {
|
||||||
|
newWebhookEvents.value = Object.fromEntries(
|
||||||
|
events.map(event => [event, false]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.availableEvents, (events) => {
|
||||||
|
if (events) initEvents(events)
|
||||||
|
}, {immediate: true})
|
||||||
|
|
||||||
|
const webhookTargetUrlValid = ref(true)
|
||||||
|
const selectedEventsValid = ref(true)
|
||||||
|
const showDeleteModal = ref(false)
|
||||||
|
const webhookIdToDelete = ref<number>()
|
||||||
|
|
||||||
|
function validateTargetUrl() {
|
||||||
|
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedEventsArray() {
|
||||||
|
return Object.entries(newWebhookEvents.value)
|
||||||
|
.filter(([, use]) => use)
|
||||||
|
.map(([event]) => event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSelectedEvents() {
|
||||||
|
const events = getSelectedEventsArray()
|
||||||
|
selectedEventsValid.value = events.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function create() {
|
||||||
|
validateTargetUrl()
|
||||||
|
if (!webhookTargetUrlValid.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEvents = getSelectedEventsArray()
|
||||||
|
newWebhook.value.events = selectedEvents
|
||||||
|
|
||||||
|
validateSelectedEvents()
|
||||||
|
if (!selectedEventsValid.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('create', newWebhook.value)
|
||||||
|
newWebhook.value = new WebhookModel()
|
||||||
|
initEvents(props.availableEvents)
|
||||||
|
showNewForm.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(webhookId: number) {
|
||||||
|
webhookIdToDelete.value = webhookId
|
||||||
|
showDeleteModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDelete() {
|
||||||
|
if (webhookIdToDelete.value) {
|
||||||
|
emit('delete', webhookIdToDelete.value)
|
||||||
|
}
|
||||||
|
showDeleteModal.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<XButton
|
||||||
|
v-if="!(webhooks?.length === 0 || showNewForm)"
|
||||||
|
icon="plus"
|
||||||
|
class="mbe-4"
|
||||||
|
@click="showNewForm = true"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.create') }}
|
||||||
|
</XButton>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="webhooks?.length === 0 || showNewForm"
|
||||||
|
class="p-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
id="targetUrl"
|
||||||
|
v-model="newWebhook.targetUrl"
|
||||||
|
:label="$t('project.webhooks.targetUrl')"
|
||||||
|
required
|
||||||
|
:placeholder="$t('project.webhooks.targetUrl')"
|
||||||
|
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
|
||||||
|
@focusout="validateTargetUrl"
|
||||||
|
/>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
class="label"
|
||||||
|
for="secret"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.secret') }}
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
id="secret"
|
||||||
|
v-model="newWebhook.secret"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
{{ $t('project.webhooks.secretHint') }}
|
||||||
|
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
||||||
|
{{ $t('project.webhooks.secretDocs') }}
|
||||||
|
</BaseButton>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BaseButton
|
||||||
|
class="mbe-2 has-text-primary"
|
||||||
|
@click="showBasicAuth = !showBasicAuth"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.basicauthlink') }}
|
||||||
|
</BaseButton>
|
||||||
|
<Expandable
|
||||||
|
:open="showBasicAuth"
|
||||||
|
class="content"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
class="label"
|
||||||
|
for="basicauthuser"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.basicauthuser') }}
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
id="basicauthuser"
|
||||||
|
v-model="newWebhook.basicauthuser"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
class="label"
|
||||||
|
for="basicauthpassword"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.basicauthpassword') }}
|
||||||
|
</label>
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
id="basicauthpassword"
|
||||||
|
v-model="newWebhook.basicauthpassword"
|
||||||
|
class="input"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Expandable>
|
||||||
|
<div class="field">
|
||||||
|
<label
|
||||||
|
class="label"
|
||||||
|
for="events"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.events') }}
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
{{ $t('project.webhooks.eventsHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="control">
|
||||||
|
<FancyCheckbox
|
||||||
|
v-for="event in availableEvents"
|
||||||
|
:key="event"
|
||||||
|
v-model="newWebhookEvents[event]"
|
||||||
|
class="available-events-check"
|
||||||
|
@update:modelValue="validateSelectedEvents"
|
||||||
|
>
|
||||||
|
{{ event }}
|
||||||
|
</FancyCheckbox>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="!selectedEventsValid"
|
||||||
|
class="help is-danger"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.mustSelectEvents') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<XButton
|
||||||
|
icon="plus"
|
||||||
|
@click="create"
|
||||||
|
>
|
||||||
|
{{ $t('project.webhooks.create') }}
|
||||||
|
</XButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table
|
||||||
|
v-if="webhooks?.length > 0"
|
||||||
|
class="table has-actions is-striped is-hoverable is-fullwidth"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('project.webhooks.targetUrl') }}</th>
|
||||||
|
<th>{{ $t('project.webhooks.events') }}</th>
|
||||||
|
<th>{{ $t('misc.created') }}</th>
|
||||||
|
<th>{{ $t('misc.createdBy') }}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="w in webhooks"
|
||||||
|
:key="w.id"
|
||||||
|
>
|
||||||
|
<td>{{ w.targetUrl }}</td>
|
||||||
|
<td>{{ w.events.join(', ') }}</td>
|
||||||
|
<td>{{ formatDateShort(w.created) }}</td>
|
||||||
|
<td>
|
||||||
|
<User
|
||||||
|
:avatar-size="25"
|
||||||
|
:user="w.createdBy"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="actions">
|
||||||
|
<XButton
|
||||||
|
danger
|
||||||
|
icon="trash-alt"
|
||||||
|
@click="() => confirmDelete(w.id)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:enabled="showDeleteModal"
|
||||||
|
@close="showDeleteModal = false"
|
||||||
|
@submit="doDelete()"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ $t('project.webhooks.delete') }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #text>
|
||||||
|
<p>{{ $t('project.webhooks.deleteText') }}</p>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.available-events-check {
|
||||||
|
margin-inline-end: .5rem;
|
||||||
|
inline-size: 12.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -186,6 +186,10 @@
|
|||||||
"backgroundBrightness": {
|
"backgroundBrightness": {
|
||||||
"title": "Background brightness"
|
"title": "Background brightness"
|
||||||
},
|
},
|
||||||
|
"webhooks": {
|
||||||
|
"title": "Webhook Notifications",
|
||||||
|
"description": "Configure webhook URLs to receive POST requests when reminder or overdue events fire. These webhooks receive events from all your projects."
|
||||||
|
},
|
||||||
"apiTokens": {
|
"apiTokens": {
|
||||||
"title": "API Tokens",
|
"title": "API Tokens",
|
||||||
"general": "API tokens allow you to use Vikunja's API without user credentials.",
|
"general": "API tokens allow you to use Vikunja's API without user credentials.",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import type {IAbstract} from './IAbstract'
|
import type {IAbstract} from './IAbstract'
|
||||||
import type {IProject} from './IProject'
|
import type {IProject} from './IProject'
|
||||||
import type {PrefixMode} from '@/modules/parseTaskText'
|
import type {PrefixMode} from '@/modules/parseTaskText'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {IUser} from '@/modelTypes/IUser'
|
|||||||
export interface IWebhook extends IAbstract {
|
export interface IWebhook extends IAbstract {
|
||||||
id: number
|
id: number
|
||||||
projectId: number
|
projectId: number
|
||||||
|
userId: number
|
||||||
secret: string
|
secret: string
|
||||||
basicauthuser: string
|
basicauthuser: string
|
||||||
basicauthpassword: string
|
basicauthpassword: string
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
|
|||||||
quickAddMagicMode: PrefixMode.Default,
|
quickAddMagicMode: PrefixMode.Default,
|
||||||
colorSchema: 'auto',
|
colorSchema: 'auto',
|
||||||
allowIconChanges: true,
|
allowIconChanges: true,
|
||||||
|
filterIdUsedOnOverview: null,
|
||||||
defaultView: DEFAULT_PROJECT_VIEW_SETTINGS.FIRST,
|
defaultView: DEFAULT_PROJECT_VIEW_SETTINGS.FIRST,
|
||||||
minimumPriority: PRIORITIES.MEDIUM,
|
minimumPriority: PRIORITIES.MEDIUM,
|
||||||
dateDisplay: DATE_DISPLAY.RELATIVE,
|
dateDisplay: DATE_DISPLAY.RELATIVE,
|
||||||
timeFormat: TIME_FORMAT.HOURS_24,
|
timeFormat: TIME_FORMAT.HOURS_24,
|
||||||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||||
|
backgroundBrightness: null,
|
||||||
alwaysShowBucketTaskCount: false,
|
alwaysShowBucketTaskCount: false,
|
||||||
sidebarWidth: null,
|
sidebarWidth: null,
|
||||||
commentSortOrder: 'asc',
|
commentSortOrder: 'asc',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import UserModel from '@/models/user'
|
|||||||
export default class WebhookModel extends AbstractModel<IWebhook> implements IWebhook {
|
export default class WebhookModel extends AbstractModel<IWebhook> implements IWebhook {
|
||||||
id = 0
|
id = 0
|
||||||
projectId = 0
|
projectId = 0
|
||||||
|
userId = 0
|
||||||
secret = ''
|
secret = ''
|
||||||
basicauthuser = ''
|
basicauthuser = ''
|
||||||
basicauthpassword = ''
|
basicauthpassword = ''
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ const router = createRouter({
|
|||||||
name: 'user.settings.sessions',
|
name: 'user.settings.sessions',
|
||||||
component: () => import('@/views/user/settings/Sessions.vue'),
|
component: () => import('@/views/user/settings/Sessions.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/user/settings/webhooks',
|
||||||
|
name: 'user.settings.webhooks',
|
||||||
|
component: () => import('@/views/user/settings/Webhooks.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/user/settings/migrate',
|
path: '/user/settings/migrate',
|
||||||
name: 'migrate.start',
|
name: 'migrate.start',
|
||||||
|
|||||||
@@ -27,3 +27,29 @@ export default class WebhookService extends AbstractService<IWebhook> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UserWebhookService extends AbstractService<IWebhook> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
getAll: '/user/settings/webhooks',
|
||||||
|
create: '/user/settings/webhooks',
|
||||||
|
update: '/user/settings/webhooks/{id}',
|
||||||
|
delete: '/user/settings/webhooks/{id}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
modelFactory(data) {
|
||||||
|
return new WebhookModel(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableEvents(): Promise<string[]> {
|
||||||
|
const cancel = this.setLoading()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.http.get('/user/settings/webhooks/events')
|
||||||
|
return response.data
|
||||||
|
} finally {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ConfigState {
|
|||||||
userDeletionEnabled: boolean,
|
userDeletionEnabled: boolean,
|
||||||
taskCommentsEnabled: boolean,
|
taskCommentsEnabled: boolean,
|
||||||
demoModeEnabled: boolean,
|
demoModeEnabled: boolean,
|
||||||
|
webhooksEnabled: boolean,
|
||||||
auth: {
|
auth: {
|
||||||
local: {
|
local: {
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@@ -66,6 +67,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||||||
userDeletionEnabled: true,
|
userDeletionEnabled: true,
|
||||||
taskCommentsEnabled: true,
|
taskCommentsEnabled: true,
|
||||||
demoModeEnabled: false,
|
demoModeEnabled: false,
|
||||||
|
webhooksEnabled: false,
|
||||||
auth: {
|
auth: {
|
||||||
local: {
|
local: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -7,21 +7,14 @@ import {useTitle} from '@vueuse/core'
|
|||||||
import ProjectService from '@/services/project'
|
import ProjectService from '@/services/project'
|
||||||
import ProjectModel from '@/models/project'
|
import ProjectModel from '@/models/project'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||||
|
|
||||||
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
||||||
|
import WebhookManager from '@/components/misc/WebhookManager.vue'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import type {IWebhook} from '@/modelTypes/IWebhook'
|
|
||||||
import WebhookService from '@/services/webhook'
|
import WebhookService from '@/services/webhook'
|
||||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
|
||||||
import User from '@/components/misc/User.vue'
|
|
||||||
import WebhookModel from '@/models/webhook'
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
|
||||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
|
||||||
import FormField from '@/components/input/FormField.vue'
|
|
||||||
import Expandable from '@/components/base/Expandable.vue'
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {isValidHttpUrl} from '@/helpers/isValidHttpUrl'
|
|
||||||
|
|
||||||
defineOptions({name: 'ProjectSettingWebhooks'})
|
defineOptions({name: 'ProjectSettingWebhooks'})
|
||||||
|
|
||||||
@@ -30,9 +23,6 @@ const {t} = useI18n({useScope: 'global'})
|
|||||||
const project = ref<IProject>()
|
const project = ref<IProject>()
|
||||||
useTitle(t('project.webhooks.title'))
|
useTitle(t('project.webhooks.title'))
|
||||||
|
|
||||||
const showNewForm = ref(false)
|
|
||||||
const showBasicAuth = ref(false)
|
|
||||||
|
|
||||||
async function loadProject(projectId: number) {
|
async function loadProject(projectId: number) {
|
||||||
const projectService = new ProjectService()
|
const projectService = new ProjectService()
|
||||||
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
const newProject = await projectService.get(new ProjectModel({id: projectId}))
|
||||||
@@ -49,75 +39,35 @@ const projectId = computed(() => route.params.projectId !== undefined
|
|||||||
|
|
||||||
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
watchEffect(() => projectId.value !== undefined && loadProject(projectId.value))
|
||||||
|
|
||||||
const webhooks = ref<IWebhook[]>()
|
const webhooks = ref<IWebhook[]>([])
|
||||||
const webhookService = new WebhookService()
|
const webhookService = new WebhookService()
|
||||||
const availableEvents = ref<string[]>()
|
const availableEvents = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
async function loadWebhooks() {
|
async function loadWebhooks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
webhooks.value = await webhookService.getAll({projectId: project.value.id})
|
webhooks.value = await webhookService.getAll({projectId: project.value.id})
|
||||||
availableEvents.value = await webhookService.getAvailableEvents()
|
availableEvents.value = await webhookService.getAvailableEvents()
|
||||||
// Initialize all events to false to avoid undefined modelValue errors
|
} finally {
|
||||||
newWebhookEvents.value = Object.fromEntries(
|
loading.value = false
|
||||||
availableEvents.value.map(event => [event, false]),
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDeleteModal = ref(false)
|
async function handleCreate(webhook: IWebhook) {
|
||||||
const webhookIdToDelete = ref<number>()
|
webhook.projectId = project.value.id
|
||||||
|
const created = await webhookService.create(webhook)
|
||||||
|
webhooks.value.push(created)
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteWebhook() {
|
async function handleDelete(webhookId: number) {
|
||||||
await webhookService.delete({
|
await webhookService.delete({
|
||||||
id: webhookIdToDelete.value,
|
id: webhookId,
|
||||||
projectId: project.value.id,
|
projectId: project.value.id,
|
||||||
})
|
})
|
||||||
showDeleteModal.value = false
|
|
||||||
success({message: t('project.webhooks.deleteSuccess')})
|
success({message: t('project.webhooks.deleteSuccess')})
|
||||||
await loadWebhooks()
|
await loadWebhooks()
|
||||||
}
|
}
|
||||||
|
|
||||||
const newWebhook = ref(new WebhookModel())
|
|
||||||
const newWebhookEvents = ref({})
|
|
||||||
|
|
||||||
async function create() {
|
|
||||||
|
|
||||||
validateTargetUrl()
|
|
||||||
if (!webhookTargetUrlValid.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedEvents = getSelectedEventsArray()
|
|
||||||
newWebhook.value.events = selectedEvents
|
|
||||||
|
|
||||||
validateSelectedEvents()
|
|
||||||
if (!selectedEventsValid.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newWebhook.value.projectId = project.value.id
|
|
||||||
const created = await webhookService.create(newWebhook.value)
|
|
||||||
webhooks.value.push(created)
|
|
||||||
newWebhook.value = new WebhookModel()
|
|
||||||
showNewForm.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookTargetUrlValid = ref(true)
|
|
||||||
|
|
||||||
function validateTargetUrl() {
|
|
||||||
webhookTargetUrlValid.value = isValidHttpUrl(newWebhook.value.targetUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedEventsValid = ref(true)
|
|
||||||
|
|
||||||
function getSelectedEventsArray() {
|
|
||||||
return Object.entries(newWebhookEvents.value)
|
|
||||||
.filter(([, use]) => use)
|
|
||||||
.map(([event]) => event)
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSelectedEvents() {
|
|
||||||
const events = getSelectedEventsArray()
|
|
||||||
selectedEventsValid.value = events.length > 0
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -126,186 +76,12 @@ function validateSelectedEvents() {
|
|||||||
:has-primary-action="false"
|
:has-primary-action="false"
|
||||||
:wide="true"
|
:wide="true"
|
||||||
>
|
>
|
||||||
<XButton
|
<WebhookManager
|
||||||
v-if="!(webhooks?.length === 0 || showNewForm)"
|
:webhooks="webhooks"
|
||||||
icon="plus"
|
:available-events="availableEvents"
|
||||||
class="mbe-4"
|
:loading="loading"
|
||||||
@click="showNewForm = true"
|
@create="handleCreate"
|
||||||
>
|
@delete="handleDelete"
|
||||||
{{ $t('project.webhooks.create') }}
|
|
||||||
</XButton>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="webhooks?.length === 0 || showNewForm"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
id="targetUrl"
|
|
||||||
v-model="newWebhook.targetUrl"
|
|
||||||
:label="$t('project.webhooks.targetUrl')"
|
|
||||||
required
|
|
||||||
:placeholder="$t('project.webhooks.targetUrl')"
|
|
||||||
:error="webhookTargetUrlValid ? null : $t('project.webhooks.targetUrlInvalid')"
|
|
||||||
@focusout="validateTargetUrl"
|
|
||||||
/>
|
/>
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
class="label"
|
|
||||||
for="secret"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.secret') }}
|
|
||||||
</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="secret"
|
|
||||||
v-model="newWebhook.secret"
|
|
||||||
class="input"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<p class="help">
|
|
||||||
{{ $t('project.webhooks.secretHint') }}
|
|
||||||
<BaseButton href="https://vikunja.io/docs/webhooks/">
|
|
||||||
{{ $t('project.webhooks.secretDocs') }}
|
|
||||||
</BaseButton>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton
|
|
||||||
class="mbe-2 has-text-primary"
|
|
||||||
@click="showBasicAuth = !showBasicAuth"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.basicauthlink') }}
|
|
||||||
</BaseButton>
|
|
||||||
<Expandable
|
|
||||||
:open="showBasicAuth"
|
|
||||||
class="content"
|
|
||||||
>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
class="label"
|
|
||||||
for="basicauthuser"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.basicauthuser') }}
|
|
||||||
</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="basicauthuser"
|
|
||||||
v-model="newWebhook.basicauthuser"
|
|
||||||
class="input"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
class="label"
|
|
||||||
for="basicauthpassword"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.basicauthpassword') }}
|
|
||||||
</label>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
id="basicauthpassword"
|
|
||||||
v-model="newWebhook.basicauthpassword"
|
|
||||||
class="input"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Expandable>
|
|
||||||
<div class="field">
|
|
||||||
<label
|
|
||||||
class="label"
|
|
||||||
for="secret"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.events') }}
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
{{ $t('project.webhooks.eventsHint') }}
|
|
||||||
</p>
|
|
||||||
<div class="control">
|
|
||||||
<FancyCheckbox
|
|
||||||
v-for="event in availableEvents"
|
|
||||||
:key="event"
|
|
||||||
v-model="newWebhookEvents[event]"
|
|
||||||
class="available-events-check"
|
|
||||||
@update:modelValue="validateSelectedEvents"
|
|
||||||
>
|
|
||||||
{{ event }}
|
|
||||||
</FancyCheckbox>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="!selectedEventsValid"
|
|
||||||
class="help is-danger"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.mustSelectEvents') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<XButton
|
|
||||||
icon="plus"
|
|
||||||
@click="create"
|
|
||||||
>
|
|
||||||
{{ $t('project.webhooks.create') }}
|
|
||||||
</XButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="webhooks?.length > 0"
|
|
||||||
class="has-horizontal-overflow"
|
|
||||||
>
|
|
||||||
<table class="table has-actions is-striped is-hoverable is-fullwidth">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ $t('project.webhooks.targetUrl') }}</th>
|
|
||||||
<th>{{ $t('project.webhooks.events') }}</th>
|
|
||||||
<th>{{ $t('misc.created') }}</th>
|
|
||||||
<th>{{ $t('misc.createdBy') }}</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="w in webhooks"
|
|
||||||
:key="w.id"
|
|
||||||
>
|
|
||||||
<td>{{ w.targetUrl }}</td>
|
|
||||||
<td>{{ w.events.join(', ') }}</td>
|
|
||||||
<td>{{ formatDateShort(w.created) }}</td>
|
|
||||||
<td>
|
|
||||||
<User
|
|
||||||
:avatar-size="25"
|
|
||||||
:user="w.createdBy"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="actions">
|
|
||||||
<XButton
|
|
||||||
danger
|
|
||||||
icon="trash-alt"
|
|
||||||
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
:enabled="showDeleteModal"
|
|
||||||
@close="showDeleteModal = false"
|
|
||||||
@submit="deleteWebhook()"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<span>{{ $t('project.webhooks.delete') }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #text>
|
|
||||||
<p>{{ $t('project.webhooks.deleteText') }}</p>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</CreateEdit>
|
</CreateEdit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.available-events-check {
|
|
||||||
margin-inline-end: .5rem;
|
|
||||||
inline-size: 12.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const caldavEnabled = computed(() => configStore.caldavEnabled)
|
|||||||
const migratorsEnabled = computed(() => configStore.migratorsEnabled)
|
const migratorsEnabled = computed(() => configStore.migratorsEnabled)
|
||||||
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
const isLocalUser = computed(() => authStore.info?.isLocalUser)
|
||||||
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
|
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
|
||||||
|
const webhooksEnabled = computed(() => configStore.webhooksEnabled)
|
||||||
|
|
||||||
const navigationItems = computed(() => {
|
const navigationItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
@@ -112,6 +113,11 @@ const navigationItems = computed(() => {
|
|||||||
title: t('user.settings.sessions.title'),
|
title: t('user.settings.sessions.title'),
|
||||||
routeName: 'user.settings.sessions',
|
routeName: 'user.settings.sessions',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('user.settings.webhooks.title'),
|
||||||
|
routeName: 'user.settings.webhooks',
|
||||||
|
condition: webhooksEnabled.value,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('user.deletion.title'),
|
title: t('user.deletion.title'),
|
||||||
routeName: 'user.settings.deletion',
|
routeName: 'user.settings.deletion',
|
||||||
|
|||||||
66
frontend/src/views/user/settings/Webhooks.vue
Normal file
66
frontend/src/views/user/settings/Webhooks.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {ref, onMounted} from 'vue'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
|
import Card from '@/components/misc/Card.vue'
|
||||||
|
import WebhookManager from '@/components/misc/WebhookManager.vue'
|
||||||
|
|
||||||
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
import {success} from '@/message'
|
||||||
|
import {UserWebhookService} from '@/services/webhook'
|
||||||
|
import type {IWebhook} from '@/modelTypes/IWebhook'
|
||||||
|
|
||||||
|
defineOptions({name: 'UserSettingsWebhooks'})
|
||||||
|
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
useTitle(() => `${t('user.settings.webhooks.title')} - ${t('user.settings.title')}`)
|
||||||
|
|
||||||
|
const service = new UserWebhookService()
|
||||||
|
const webhooks = ref<IWebhook[]>([])
|
||||||
|
const availableEvents = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function loadWebhooks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
webhooks.value = await service.getAll()
|
||||||
|
availableEvents.value = await service.getAvailableEvents()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(webhook: IWebhook) {
|
||||||
|
const created = await service.create(webhook)
|
||||||
|
webhooks.value.push(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(webhookId: number) {
|
||||||
|
await service.delete({id: webhookId})
|
||||||
|
success({message: t('project.webhooks.deleteSuccess')})
|
||||||
|
await loadWebhooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadWebhooks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
:title="$t('user.settings.webhooks.title')"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<p class="mb-4">
|
||||||
|
{{ $t('user.settings.webhooks.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<WebhookManager
|
||||||
|
:webhooks="webhooks"
|
||||||
|
:available-events="availableEvents"
|
||||||
|
:loading="loading"
|
||||||
|
@create="handleCreate"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user