From 01201079c9c5af1dd145a0700859406520d2b592 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Fri, 28 Nov 2025 13:15:18 +0100 Subject: [PATCH] feat(sharing): ability to leave a shared board Signed-off-by: Luka Trovic --- appinfo/routes.php | 1 + lib/Controller/BoardController.php | 5 ++ lib/Db/AclMapper.php | 10 ++++ lib/Service/BoardService.php | 31 +++++++++++++ .../navigation/AppNavigationBoard.vue | 46 ++++++++++++++++++- src/services/BoardApi.js | 15 ++++++ 6 files changed, 107 insertions(+), 1 deletion(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 4adb828b46..45d6555f3b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -22,6 +22,7 @@ ['name' => 'board#update', 'url' => '/boards/{boardId}', 'verb' => 'PUT'], ['name' => 'board#delete', 'url' => '/boards/{boardId}', 'verb' => 'DELETE'], ['name' => 'board#deleteUndo', 'url' => '/boards/{boardId}/deleteUndo', 'verb' => 'POST'], + ['name' => 'board#leave', 'url' => '/boards/{boardId}/leave', 'verb' => 'POST'], ['name' => 'board#getUserPermissions', 'url' => '/boards/{boardId}/permissions', 'verb' => 'GET'], ['name' => 'board#addAcl', 'url' => '/boards/{boardId}/acl', 'verb' => 'POST'], ['name' => 'board#updateAcl', 'url' => '/boards/{boardId}/acl/{aclId}', 'verb' => 'PUT'], diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index eb2b00ac0b..1b17071b78 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -63,6 +63,11 @@ public function deleteUndo(int $boardId): Board { return $this->boardService->deleteUndo($boardId); } + #[NoAdminRequired] + public function leave(int $boardId) { + return $this->boardService->leave($boardId); + } + #[NoAdminRequired] public function getUserPermissions(int $boardId): array { $permissions = $this->permissionService->getPermissions($boardId); diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 52c087dd5f..f85d7bf699 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -88,6 +88,16 @@ public function findByParticipant(int $type, string $participant): array { return $this->findEntities($qb); } + public function findParticipantFromBoard(int $boardId, int $type, string $participant): ?Acl { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('deck_board_acl') + ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))) + ->where($qb->expr()->eq('participant', $qb->createNamedParameter($participant, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + /** * @throws \OCP\DB\Exception */ diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index be30920161..45f439d2c1 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -432,6 +432,37 @@ public function deleteAcl(int $id): ?Acl { return $deletedAcl; } + public function leave(int $boardId): ?Acl { + if ($this->permissionService->userIsBoardOwner($boardId)) { + throw new BadRequestException('Board owner cannot leave board'); + } + + $acl = $this->aclMapper->findParticipantFromBoard($boardId, Acl::PERMISSION_TYPE_USER, $this->userId); + + if (!$acl) { + throw new BadRequestException('Not a participant of this board'); + } + + $this->assignedUsersMapper->deleteByParticipantOnBoard($acl->getParticipant(), $acl->getBoardId()); + + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $acl, ActivityManager::SUBJECT_BOARD_UNSHARE); + $this->changeHelper->boardChanged($acl->getBoardId()); + + $version = \OCP\Util::getVersion()[0]; + if ($version >= 16) { + try { + $resourceProvider = Server::get(\OCA\Deck\Collaboration\Resources\ResourceProvider::class); + $resourceProvider->invalidateAccessCache($acl->getBoardId()); + } catch (\Exception $e) { + } + } + + $deletedAcl = $this->aclMapper->delete($acl); + $this->eventDispatcher->dispatchTyped(new AclDeletedEvent($acl)); + + return $deletedAcl; + } + /** * @throws BadRequestException * @throws DbException diff --git a/src/components/navigation/AppNavigationBoard.vue b/src/components/navigation/AppNavigationBoard.vue index 14562390c1..eda417bb6f 100644 --- a/src/components/navigation/AppNavigationBoard.vue +++ b/src/components/navigation/AppNavigationBoard.vue @@ -116,6 +116,16 @@ @click="actionDelete"> {{ t('deck', 'Delete board') }} + + + + {{ t('deck', 'Leave board') }} +
@@ -156,6 +166,7 @@ import { NcAppNavigationIconBullet, NcAppNavigationItem, NcColorPicker, NcButton import ClickOutside from 'vue-click-outside' import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue' import CloneIcon from 'vue-material-design-icons/ContentDuplicate.vue' +import LeaveIcon from 'vue-material-design-icons/ExitRun.vue' import AccountIcon from 'vue-material-design-icons/AccountOutline.vue' import CloseIcon from 'vue-material-design-icons/Close.vue' import CheckIcon from 'vue-material-design-icons/Check.vue' @@ -166,7 +177,8 @@ import { emit } from '@nextcloud/event-bus' import isTouchDevice from '../../mixins/isTouchDevice.js' import BoardCloneModal from './BoardCloneModal.vue' import BoardExportModal from './BoardExportModal.vue' -import { showLoading } from '@nextcloud/dialogs' +import { showLoading, showError } from '@nextcloud/dialogs' +import { getCurrentUser } from '@nextcloud/auth' const canCreateState = loadState('deck', 'canCreate') @@ -184,6 +196,7 @@ export default { CloneIcon, CloseIcon, CheckIcon, + LeaveIcon, BoardCloneModal, BoardExportModal, }, @@ -215,6 +228,7 @@ export default { canCreate: canCreateState, cloneModalOpen: false, exportModalOpen: false, + currentUser: getCurrentUser(), } }, computed: { @@ -236,6 +250,9 @@ export default { canManage() { return this.board.permissions.PERMISSION_MANAGE }, + canLeave() { + return this.board.acl?.find((acl) => acl.participant.uid === this.currentUser?.uid && acl.participant.type === 0) !== undefined + }, dueDateReminderIcon() { if (this.board.settings['notify-due'] === 'all') { return 'icon-sound' @@ -326,6 +343,33 @@ export default { true, ) }, + actionLeave() { + OC.dialogs.confirmDestructive( + t('deck', 'Are you sure you want to leave the board {title}?', { title: this.board.title }), + t('deck', 'Leave the board?'), + { + type: OC.dialogs.YES_NO_BUTTONS, + confirm: t('deck', 'Leave'), + confirmClasses: 'error', + cancel: t('deck', 'Cancel'), + }, + (result) => { + if (result) { + this.loading = true + this.boardApi.leaveBoard(this.board) + .then(() => { + this.loading = false + this.$store.dispatch('removeBoard', this.board) + }) + .catch(() => { + showError(t('deck', 'Failed to leave the board')) + this.loading = false + }) + } + }, + true, + ) + }, actionDetails() { this.$router.push({ name: 'board.details', params: { id: this.board.id } }) }, diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js index 7a6ae24ff1..c3bc0584f4 100644 --- a/src/services/BoardApi.js +++ b/src/services/BoardApi.js @@ -93,6 +93,21 @@ export class BoardApi { }) } + leaveBoard(board) { + return axios.post(this.url(`/boards/${board.id}/leave`)) + .then( + () => { + return Promise.resolve() + }, + (err) => { + return Promise.reject(err) + }, + ) + .catch((err) => { + return Promise.reject(err) + }) + } + loadBoards() { return axios.get(this.url('/boards')) .then(