diff --git a/src/App.vue b/src/App.vue
index b8457d26d27..d8d3dc66916 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -378,6 +378,10 @@ export default {
}
})
+ EventBus.on('forbidden-route', (params) => {
+ this.$router.push({ name: 'forbidden' })
+ })
+
/**
* Listens to the conversationsReceived globalevent, emitted by the conversationsList
* component each time a new batch of conversations is received and processed in
diff --git a/src/__mocks__/router.js b/src/__mocks__/router.js
index 613e74aa4cc..f62163e43a8 100644
--- a/src/__mocks__/router.js
+++ b/src/__mocks__/router.js
@@ -24,6 +24,12 @@ export default new VueRouter({
component: Stub,
props: true,
},
+ {
+ path: '/apps/spreed/forbidden',
+ name: 'forbidden',
+ component: Stub,
+ props: true,
+ },
{
path: '/apps/spreed/duplicate-session',
name: 'duplicatesession',
diff --git a/src/components/ConversationSettings/BanSettings/BanSettings.vue b/src/components/ConversationSettings/BanSettings/BanSettings.vue
new file mode 100644
index 00000000000..cc40d448728
--- /dev/null
+++ b/src/components/ConversationSettings/BanSettings/BanSettings.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+ {{ t('spreed', 'Banned users') }}
+
+
+ {{ t('spreed', 'Manage the list of banned users in this conversation.') }}
+
+
+ {{ t('spreed', 'Manage bans') }}
+
+
+
+
+
+ {{ t('spreed', 'Banned users') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ isLoading ? t('spreed', 'Loading …') : t('spreed', 'No banned users') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ConversationSettings/BanSettings/BannedItem.vue b/src/components/ConversationSettings/BanSettings/BannedItem.vue
new file mode 100644
index 00000000000..cddd48399db
--- /dev/null
+++ b/src/components/ConversationSettings/BanSettings/BannedItem.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ConversationSettings/ConversationSettingsDialog.vue b/src/components/ConversationSettings/ConversationSettingsDialog.vue
index 3720cc789a5..782e9b86917 100644
--- a/src/components/ConversationSettings/ConversationSettingsDialog.vue
+++ b/src/components/ConversationSettings/ConversationSettingsDialog.vue
@@ -40,6 +40,7 @@
+
@@ -100,6 +101,7 @@ import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDia
import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import BanSettings from './BanSettings/BanSettings.vue'
import BasicInfo from './BasicInfo.vue'
import BotsSettings from './BotsSettings.vue'
import BreakoutRoomsSettings from './BreakoutRoomsSettings.vue'
@@ -123,6 +125,7 @@ export default {
name: 'ConversationSettingsDialog',
components: {
+ BanSettings,
BasicInfo,
BotsSettings,
BreakoutRoomsSettings,
@@ -180,6 +183,10 @@ export default {
return (!hasTalkFeature(this.token, 'federation-v1') || !this.conversation.remoteServer)
},
+ supportBanV1() {
+ return hasTalkFeature(this.token, 'ban-v1')
+ },
+
showMediaSettings() {
return this.settingsStore.getShowMediaSettings(this.token)
},
diff --git a/src/components/RightSidebar/Participants/Participant.spec.js b/src/components/RightSidebar/Participants/Participant.spec.js
index 6dc3eaf8804..b69bad0e19f 100644
--- a/src/components/RightSidebar/Participants/Participant.spec.js
+++ b/src/components/RightSidebar/Participants/Participant.spec.js
@@ -14,7 +14,10 @@ import VideoIcon from 'vue-material-design-icons/Video.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import Participant from './Participant.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
@@ -107,7 +110,10 @@ describe('Participant.vue', () => {
stubs: {
NcActionButton,
NcButton,
+ NcCheckboxRadioSwitch,
NcDialog,
+ NcInputField,
+ NcTextField,
},
directives: {
tooltip: tooltipMock,
@@ -639,6 +645,8 @@ describe('Participant.vue', () => {
expect(removeAction).toHaveBeenCalledWith(expect.anything(), {
token: 'current-token',
attendeeId: 'alice-attendee-id',
+ banParticipant: false,
+ internalNote: '',
})
}
@@ -651,6 +659,56 @@ describe('Participant.vue', () => {
expect(actionButton.exists()).toBe(false)
}
+ /**
+ * @param {string} buttonText Label of the remove action to find
+ * @param {string} internalNote text of provided note
+ */
+ async function testCanBan(buttonText = 'Remove participant', internalNote = 'test note') {
+ const wrapper = mountParticipant(participant)
+ const actionButton = findNcActionButton(wrapper, buttonText)
+ expect(actionButton.exists()).toBe(true)
+
+ await actionButton.find('button').trigger('click')
+
+ const dialog = wrapper.findComponent(NcDialog)
+ expect(dialog.exists()).toBeTruthy()
+
+ const checkbox = dialog.findComponent(NcCheckboxRadioSwitch)
+ await checkbox.find('input').trigger('change')
+
+ const input = dialog.findComponent(NcTextField)
+ expect(input.exists()).toBeTruthy()
+ input.find('input').setValue(internalNote)
+ await input.find('input').trigger('change')
+
+ const button = findNcButton(dialog, 'Remove')
+ await button.find('button').trigger('click')
+
+ expect(removeAction).toHaveBeenCalledWith(expect.anything(), {
+ token: 'current-token',
+ attendeeId: 'alice-attendee-id',
+ banParticipant: true,
+ internalNote
+ })
+ }
+
+ /**
+ * @param {string} buttonText Label of the remove action to find
+ */
+ async function testCannotBan(buttonText = 'Remove participant') {
+ const wrapper = mountParticipant(participant)
+ const actionButton = findNcActionButton(wrapper, buttonText)
+ expect(actionButton.exists()).toBe(true)
+
+ await actionButton.find('button').trigger('click')
+
+ const dialog = wrapper.findComponent(NcDialog)
+ expect(dialog.exists()).toBeTruthy()
+
+ const checkbox = dialog.findComponent(NcCheckboxRadioSwitch)
+ expect(checkbox.exists()).toBeFalsy()
+ }
+
test('allows a moderator to remove a moderator', async () => {
conversation.participantType = PARTICIPANT.TYPE.MODERATOR
participant.participantType = PARTICIPANT.TYPE.MODERATOR
@@ -707,6 +765,30 @@ describe('Participant.vue', () => {
conversation.participantType = PARTICIPANT.TYPE.USER
await testCannotRemove()
})
+
+ test('allows a moderator to ban a moderator', async () => {
+ conversation.participantType = PARTICIPANT.TYPE.MODERATOR
+ participant.participantType = PARTICIPANT.TYPE.USER
+ await testCanBan()
+ })
+
+ test('allows a moderator to ban a guest', async () => {
+ conversation.participantType = PARTICIPANT.TYPE.MODERATOR
+ participant.participantType = PARTICIPANT.TYPE.GUEST
+ await testCanBan()
+ })
+
+ test('does not allow a moderator to ban a moderator', async () => {
+ conversation.participantType = PARTICIPANT.TYPE.MODERATOR
+ participant.participantType = PARTICIPANT.TYPE.MODERATOR
+ await testCannotBan()
+ })
+
+ test('does not allow a moderator to ban a group', async () => {
+ conversation.participantType = PARTICIPANT.TYPE.MODERATOR
+ participant.actorType = ATTENDEE.ACTOR_TYPE.GROUPS
+ await testCannotBan('Remove group and members')
+ })
})
describe('dial-in PIN', () => {
/**
diff --git a/src/components/RightSidebar/Participants/Participant.vue b/src/components/RightSidebar/Participants/Participant.vue
index 3d344ee2e25..921258c3c02 100644
--- a/src/components/RightSidebar/Participants/Participant.vue
+++ b/src/components/RightSidebar/Participants/Participant.vue
@@ -313,6 +313,17 @@
:name="removeParticipantLabel"
:container="container">
{{ removeDialogMessage }}
+
+
+ {{ t('spreed', 'Also ban from this conversation') }}
+
+
+
+
+
{{ t('spreed', 'Dismiss') }}
@@ -363,7 +374,9 @@ import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip.js'
import ParticipantPermissionsEditor from './ParticipantPermissionsEditor.vue'
@@ -396,7 +409,9 @@ export default {
NcActionText,
NcActionSeparator,
NcButton,
+ NcCheckboxRadioSwitch,
NcDialog,
+ NcTextField,
ParticipantPermissionsEditor,
// Icons
Account,
@@ -470,6 +485,8 @@ export default {
isStatusTooltipVisible: false,
permissionsEditor: false,
isRemoveDialogOpen: false,
+ isBanParticipant: false,
+ internalNote: '',
disabled: false,
}
},
@@ -835,6 +852,10 @@ export default {
|| this.participant.actorType === ATTENDEE.ACTOR_TYPE.EMAILS)
},
+ supportBanV1() {
+ return hasTalkFeature(this.token, 'ban-v1')
+ },
+
isLobbyEnabled() {
return this.conversation.lobbyState === WEBINAR.LOBBY.NON_MODERATORS
},
@@ -977,7 +998,11 @@ export default {
await this.$store.dispatch('removeParticipant', {
token: this.token,
attendeeId: this.attendeeId,
+ banParticipant: this.isBanParticipant,
+ internalNote: this.internalNote,
})
+ this.isBanParticipant = false
+ this.internalNote = ''
this.isRemoveDialogOpen = false
},
@@ -1224,6 +1249,12 @@ export default {
cursor: pointer;
}
+.participant-dialog {
+ &__input {
+ margin-block-end: 6px;
+ }
+}
+
.utils {
&__checkmark {
margin-right: 11px;
diff --git a/src/router/router.js b/src/router/router.js
index 42c458abfed..6104a32e63a 100644
--- a/src/router/router.js
+++ b/src/router/router.js
@@ -9,6 +9,7 @@ import Router from 'vue-router'
import { getRootUrl, generateUrl } from '@nextcloud/router'
import CallView from '../components/CallView/CallView.vue'
+import ForbiddenView from '../views/ForbiddenView.vue'
import MainView from '../views/MainView.vue'
import NotFoundView from '../views/NotFoundView.vue'
import SessionConflictView from '../views/SessionConflictView.vue'
@@ -51,6 +52,12 @@ export default new Router({
component: NotFoundView,
props: true,
},
+ {
+ path: '/apps/spreed/forbidden',
+ name: 'forbidden',
+ component: ForbiddenView,
+ props: true,
+ },
{
path: '/apps/spreed/duplicate-session',
name: 'duplicatesession',
diff --git a/src/services/banService.ts b/src/services/banService.ts
new file mode 100644
index 00000000000..0f90ac1f0af
--- /dev/null
+++ b/src/services/banService.ts
@@ -0,0 +1,56 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+import type {
+ getBansResponse,
+ banActorParams,
+ banActorResponse,
+ unbanActorParams,
+ unbanActorResponse,
+} from '../types'
+
+/**
+ * Get information about configured bans for this conversation
+ *
+ * @param token - the conversation token
+ * @param [options] - request options
+ */
+const getConversationBans = async function(token: string, options?: object): getBansResponse {
+ return axios.get(generateOcsUrl('/apps/spreed/api/v1/ban/{token}', { token }, options), options)
+}
+
+/**
+ * Ban actor with specified internal note for this conversation
+ *
+ * @param token - the conversation token
+ * @param payload - banned actor information
+ * @param [options] - request options
+ */
+const banActor = async function(token: string, payload: banActorParams, options?: object): banActorResponse {
+ return axios.post(generateOcsUrl('/apps/spreed/api/v1/ban/{token}', { token }, options), payload, options)
+}
+
+/**
+ * Ban actor with specified internal note for this conversation
+ *
+ * @param token - the conversation token
+ * @param banId - ban id
+ * @param [options] - request options
+ */
+const unbanActor = async function(token: string, banId: unbanActorParams['banId'], options?: object): unbanActorResponse {
+ return axios.delete(generateOcsUrl('/apps/spreed/api/v1/ban/{token}', { token }, options), {
+ ...options,
+ params: { banId } as unbanActorParams,
+ })
+}
+
+export {
+ getConversationBans,
+ banActor,
+ unbanActor,
+}
diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js
index 240d44af9a9..c8ecffd2dbd 100644
--- a/src/store/participantsStore.js
+++ b/src/store/participantsStore.js
@@ -6,17 +6,18 @@ import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import Vue from 'vue'
-import { showError } from '@nextcloud/dialogs'
+import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { ATTENDEE, PARTICIPANT } from '../constants.js'
+import { banActor } from '../services/banService.ts'
import {
joinCall,
leaveCall,
} from '../services/callsService.js'
-import { setRemoteCapabilities } from '../services/CapabilitiesManager.ts'
+import { hasTalkFeature, setRemoteCapabilities } from '../services/CapabilitiesManager.ts'
import { EventBus } from '../services/EventBus.js'
import {
promoteToModerator,
@@ -560,12 +561,25 @@ const actions = {
commit('updateParticipant', { token, attendeeId, updatedData })
},
- async removeParticipant({ commit, getters }, { token, attendeeId }) {
+ async removeParticipant({ commit, getters }, { token, attendeeId, banParticipant, internalNote = '' }) {
const attendee = getters.getParticipant(token, attendeeId)
if (!attendee) {
return
}
+ if (hasTalkFeature(token, 'ban-v1') && banParticipant) {
+ try {
+ await banActor(token, {
+ actorId: attendee.actorId,
+ actorType: attendee.actorType,
+ internalNote,
+ })
+ showSuccess(t('spreed', 'Participant is banned successfully'))
+ } catch (error) {
+ showError(t('spreed', 'Error while banning the participant'))
+ console.error('Error while banning the participant: ', error)
+ }
+ }
await removeAttendeeFromConversation(token, attendeeId)
commit('deleteParticipant', { token, attendeeId })
},
@@ -876,6 +890,8 @@ const actions = {
} else {
await context.dispatch('confirmForceJoinConversation', { token })
}
+ } else if (error?.response?.status === 403 && error?.response?.data?.ocs?.data?.error === 'ban') {
+ EventBus.emit('forbidden-route', error.response.data.ocs.data)
} else {
console.error(error)
showError(t('spreed', 'Failed to join the conversation. Try to reload the page.'))
diff --git a/src/types/index.ts b/src/types/index.ts
index 5b7f65eba5b..e733416a64a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -89,6 +89,15 @@ export type setEmojiAvatarParams = ApiOptions
export type deleteAvatarResponse = ApiResponse
+// Bans
+export type Ban = components['schemas']['Ban']
+
+export type getBansResponse = ApiResponse
+export type banActorParams = ApiOptions['params']
+export type banActorResponse = ApiResponse
+export type unbanActorParams = ApiOptions['params']
+export type unbanActorResponse = ApiResponse
+
// Bots
export type Bot = components['schemas']['Bot']
export type BotWithDetails = components['schemas']['BotWithDetails']
diff --git a/src/views/ForbiddenView.vue b/src/views/ForbiddenView.vue
new file mode 100644
index 00000000000..d44e077d291
--- /dev/null
+++ b/src/views/ForbiddenView.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+