From 526b6b5bada465af245bf0854b9f7e09fcbe642d Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 19:14:23 +0200 Subject: [PATCH 01/27] feat: add owner_type field to boards for circle ownership support Add an owner_type column to deck_boards (SMALLINT, default 0) that mirrors the existing Acl::PERMISSION_TYPE_* constants. A value of 0 means the owner is a user (preserving all existing behaviour); 7 means the owner is a circle/team. - DB migration Version11002Date20260429000000 adds the column idempotently - Board entity gains $ownerType property, type registration, docblock accessors, and automatic serialisation into API responses as ownerType - BoardMapper: add owner_type to every explicit SELECT column list so the field is populated when entities are hydrated from those queries (SELECT * queries already include it automatically) - BoardTest: update all jsonSerialize assertions to expect ownerType: 0 No functional changes in this commit; subsequent steps will wire up permission checks, transfer logic, and the UI. AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + lib/Db/Board.php | 4 ++ lib/Db/BoardMapper.php | 14 +++---- .../Version11002Date20260429000000.php | 41 +++++++++++++++++++ tests/unit/Db/BoardTest.php | 4 ++ 5 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 lib/Migration/Version11002Date20260429000000.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be848709e..3c8a661473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. ## 1.16.0-beta.1 ### Added +- feat: add owner_type to boards for circle ownership support @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/lib/Db/Board.php b/lib/Db/Board.php index a0663dbd3d..80865dcb67 100644 --- a/lib/Db/Board.php +++ b/lib/Db/Board.php @@ -22,6 +22,8 @@ * @method void setLastModified(int $lastModified) * @method string getOwner() * @method void setOwner(string $owner) + * @method int getOwnerType() + * @method void setOwnerType(int $ownerType) * @method string getColor() * @method void setColor(string $color) * @method void setShareToken(string $shareToken) @@ -32,6 +34,7 @@ class Board extends RelationalEntity { protected $title; protected $owner; + protected $ownerType = 0; protected $color; protected $archived = false; /** @var Label[]|null */ @@ -53,6 +56,7 @@ class Board extends RelationalEntity { public function __construct() { $this->addType('id', 'integer'); $this->addType('shared', 'integer'); + $this->addType('ownerType', 'integer'); $this->addType('archived', 'boolean'); $this->addType('deletedAt', 'integer'); $this->addType('lastModified', 'integer'); diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index 2583a117cd..723a9ca5de 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -192,7 +192,7 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset = // FIXME this used to be a UNION to get boards owned by $userId and the user shares in one single query // Is it possible with the query builder? $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') // this does not work in MySQL/PostgreSQL //->selectAlias('0', 'shared') ->from('deck_boards', 'b') @@ -232,7 +232,7 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset = // shared with user $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') //->selectAlias('1', 'shared') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) @@ -300,7 +300,7 @@ public function findAllByGroups(string $userId, array $groups, ?int $limit = nul return []; } $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') //->selectAlias('2', 'shared') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) @@ -356,7 +356,7 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse } $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') //->selectAlias('2', 'shared') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) @@ -406,7 +406,7 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse public function findAllByTeam(string $teamId): array { $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) ->where($qb->expr()->eq('acl.type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT))) @@ -434,7 +434,7 @@ public function findTeamsForBoard(int $boardId): array { public function isSharedWithTeam(int $boardId, string $teamId): bool { $qb = $this->db->getQueryBuilder(); - $qb->select('b.id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') ->from('deck_boards', 'b') ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) ->where($qb->expr()->eq('b.id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) @@ -460,7 +460,7 @@ public function findToDelete() { // add buffer of 5 min $timeLimit = time() - (60 * 5); $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'title', 'owner', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + $qb->select('id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') ->from('deck_boards') ->where($qb->expr()->gt('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($timeLimit, IQueryBuilder::PARAM_INT))); diff --git a/lib/Migration/Version11002Date20260429000000.php b/lib/Migration/Version11002Date20260429000000.php new file mode 100644 index 0000000000..972687399a --- /dev/null +++ b/lib/Migration/Version11002Date20260429000000.php @@ -0,0 +1,41 @@ +hasTable('deck_boards')) { + $table = $schema->getTable('deck_boards'); + if (!$table->hasColumn('owner_type')) { + $table->addColumn('owner_type', 'smallint', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => false, + ]); + } + } + + return $schema; + } +} diff --git a/tests/unit/Db/BoardTest.php b/tests/unit/Db/BoardTest.php index 5d729b518e..0fff6620b0 100644 --- a/tests/unit/Db/BoardTest.php +++ b/tests/unit/Db/BoardTest.php @@ -22,6 +22,7 @@ public function testJsonSerialize() { 'id' => 1, 'title' => 'My Board', 'owner' => 'admin', + 'ownerType' => 0, 'color' => '000000', 'labels' => [], 'permissions' => [], @@ -48,6 +49,7 @@ public function testUnfetchedValues() { 'id' => 1, 'title' => 'My Board', 'owner' => 'admin', + 'ownerType' => 0, 'color' => '000000', 'labels' => [], 'permissions' => [], @@ -72,6 +74,7 @@ public function testSetLabels() { 'id' => 1, 'title' => 'My Board', 'owner' => 'admin', + 'ownerType' => 0, 'color' => '000000', 'labels' => ['foo', 'bar'], 'permissions' => [], @@ -103,6 +106,7 @@ public function testSetShared() { 'id' => 1, 'title' => 'My Board', 'owner' => 'admin', + 'ownerType' => 0, 'color' => '000000', 'labels' => [], 'permissions' => [], From e8d455bde56718e6b67777ac437f742997b22a60 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 19:29:00 +0200 Subject: [PATCH 02/27] feat: wire circle ownership into BoardMapper mapOwner(): when owner_type = PERMISSION_TYPE_CIRCLE (7), resolve the owner string as a circle ID via CirclesService and return a Circle object, matching the behaviour already used in mapAcl() for circle ACL entries. The federated-user and plain-user paths are unchanged. findAllByCircleOwner(): new method that finds boards where owner_type = 7 and owner is a circle the requesting user belongs to. Follows the same filter-parameter contract as the other findAllBy* methods; sets shared = 0 (user is effectively an owner, not just a collaborator). findAllForUser(): includes findAllByCircleOwner() results in the merged board list alongside the existing user, group, and circle-share sources. findBoardIds(): adds a third query segment for circle-owned boards, reusing the $circles list already fetched for the circle-share segment. transferOwnership(): adds an optional $newOwnerType parameter (default PERMISSION_TYPE_USER, placed after $boardId to preserve backward compatibility) and stores it as owner_type in the UPDATE, so a future transfer to a circle atomically sets both owner and owner_type. No functional change for existing user-owned boards; all new paths either return empty results (no circles app / user in no circles) or are blocked by the as-yet-unchanged PermissionService (step 4). AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + lib/Db/BoardMapper.php | 99 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8a661473..f38327ac5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. ### Added - feat: add owner_type to boards for circle ownership support @jospoortvliet +- feat: resolve circle board owners in BoardMapper and include circle-owned boards in user board queries @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index 723a9ca5de..dfdd5a84af 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -131,7 +131,23 @@ public function findBoardIds(string $userId): array { return (int)$id; }, $result->fetchAll(\PDO::FETCH_COLUMN)); $result->closeCursor(); - return array_unique(array_merge($ownerBoards, $sharedBoards)); + + // Owned by circles the user is in (reuse $circles already fetched above) + $circleOwnedBoards = []; + if (count($circles) !== 0) { + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct('b.id') + ->from($this->getTableName(), 'b') + ->where($qb->expr()->eq('owner_type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('owner', $qb->createNamedParameter($circles, IQueryBuilder::PARAM_STR_ARRAY))); + $result = $qb->executeQuery(); + $circleOwnedBoards = array_map(function (string $id) { + return (int)$id; + }, $result->fetchAll(\PDO::FETCH_COLUMN)); + $result->closeCursor(); + } + + return array_unique(array_merge($ownerBoards, $sharedBoards, $circleOwnedBoards)); } /** * @param int $externalId @@ -156,7 +172,8 @@ public function findAllForUser(string $userId, ?int $since = null, bool $include $userBoards = $this->findAllByUser($userId, null, null, $since, $includeArchived, $before, $term); $groupBoards = $this->findAllByGroups($userId, $groups, null, null, $since, $includeArchived, $before, $term); $circleBoards = $this->findAllByCircles($userId, null, null, $since, $includeArchived, $before, $term); - $allBoards = array_values(array_unique(array_merge($userBoards, $groupBoards, $circleBoards))); + $circleOwnedBoards = $this->findAllByCircleOwner($userId, null, null, $since, $includeArchived, $before, $term); + $allBoards = array_values(array_unique(array_merge($userBoards, $groupBoards, $circleBoards, $circleOwnedBoards))); // Could be moved outside $acls = $this->aclMapper->findIn(array_map(function ($board) { @@ -404,6 +421,60 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse return $entries; } + /** + * Find all boards that are owned by a circle the given user is a member of. + * + * These are boards where owner_type = PERMISSION_TYPE_CIRCLE and the owner field + * holds a circle ID that the user belongs to. They are distinct from boards that + * are merely *shared* with a circle via an ACL entry (handled by findAllByCircles). + */ + public function findAllByCircleOwner(string $userId, ?int $limit = null, ?int $offset = null, ?int $since = null, + bool $includeArchived = true, ?int $before = null, ?string $term = null): array { + $circles = $this->circlesService->getUserCircles($userId); + if (count($circles) === 0) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') + ->from('deck_boards') + ->where($qb->expr()->eq('owner_type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('owner', $qb->createNamedParameter($circles, IQueryBuilder::PARAM_STR_ARRAY))); + if (!$includeArchived) { + $qb->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + } + if ($since !== null) { + $qb->andWhere($qb->expr()->gt('last_modified', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT))); + } + if ($before !== null) { + $qb->andWhere($qb->expr()->lt('last_modified', $qb->createNamedParameter($before, IQueryBuilder::PARAM_INT))); + } + if ($term !== null) { + $qb->andWhere( + $qb->expr()->iLike( + 'title', + $qb->createNamedParameter( + '%' . $this->db->escapeLikeParameter($term) . '%', + IQueryBuilder::PARAM_STR + ) + ) + ); + } + $qb->orderBy('id'); + if ($limit !== null) { + $qb->setMaxResults($limit); + } + if ($offset !== null) { + $qb->setFirstResult($offset); + } + $entries = $this->findEntities($qb); + foreach ($entries as $entry) { + $entry->setShared(0); + } + return $entries; + } + public function findAllByTeam(string $teamId): array { $qb = $this->db->getQueryBuilder(); $qb->select('b.id', 'title', 'owner', 'owner_type', 'color', 'archived', 'deleted_at', 'last_modified', 'external_id', 'share_token') @@ -540,11 +611,28 @@ public function mapAcl(Acl &$acl): void { /** * @param Board $board */ - public function mapOwner(Board &$board) { + public function mapOwner(Board &$board): void { $userManager = $this->userManager; $cloudIdManager = $this->cloudIdManager; + $circlesService = $this->circlesService; + $logger = $this->logger; $externalId = $board->getExternalId(); - $board->resolveRelation('owner', function ($owner) use (&$userManager, &$cloudIdManager, $externalId) { + $ownerType = $board->getOwnerType(); + $board->resolveRelation('owner', function ($owner) use ($userManager, $cloudIdManager, $circlesService, $logger, $externalId, $ownerType) { + if ($ownerType === Acl::PERMISSION_TYPE_CIRCLE) { + if (!$circlesService->isCirclesEnabled()) { + return null; + } + try { + $circle = $circlesService->getCircle($owner); + if ($circle !== null) { + return new Circle($circle); + } + } catch (\Throwable $e) { + $logger->error('Failed to get circle details when mapping board owner', ['exception' => $e]); + } + return null; + } if ($externalId !== null) { $cloudId = $cloudIdManager->resolveCloudId($owner); return new FederatedUser($cloudId); @@ -559,10 +647,11 @@ public function mapOwner(Board &$board) { /** * @throws \OCP\DB\Exception */ - public function transferOwnership(string $ownerId, string $newOwnerId, $boardId = null): void { + public function transferOwnership(string $ownerId, string $newOwnerId, ?int $boardId = null, int $newOwnerType = Acl::PERMISSION_TYPE_USER): void { $qb = $this->db->getQueryBuilder(); $qb->update('deck_boards') ->set('owner', $qb->createNamedParameter($newOwnerId, IQueryBuilder::PARAM_STR)) + ->set('owner_type', $qb->createNamedParameter($newOwnerType, IQueryBuilder::PARAM_INT)) ->where($qb->expr()->eq('owner', $qb->createNamedParameter($ownerId, IQueryBuilder::PARAM_STR))); if ($boardId !== null) { $qb->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))); From a107be6a2999ce12a2663864efe3252a312e198c Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 19:30:04 +0200 Subject: [PATCH 03/27] feat: grant owner-level permissions to circle members on circle-owned boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userIsBoardOwner(): when the board's owner_type is PERMISSION_TYPE_CIRCLE, delegate to CirclesService::isUserInCircle() instead of comparing the owner string directly to the user ID. Because getPermissions() and matchPermissions() both gate every permission on userIsBoardOwner(), this single change gives every circle member full read/edit/manage/share access to a circle-owned board with no further changes to the permission stack. findUsers(): for circle-owned boards the owner field holds a circle ID, not a user ID, so the existing "add board owner as a User" path would create a dangling entry. It is replaced by an expansion of the owning circle's inherited members, reusing the same Member::LEVEL_MEMBER + getUserType()===1 guard already present for circle ACL entries below. Tests: add testUserIsBoardOwnerCircleMember covering the member→true and non-member→false cases for a circle-owned board. AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + lib/Service/PermissionService.php | 31 ++++++++++++++++++-- tests/unit/Service/PermissionServiceTest.php | 21 +++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f38327ac5f..80ec22c2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. ### Added - feat: add owner_type to boards for circle ownership support @jospoortvliet - feat: resolve circle board owners in BoardMapper and include circle-owned boards in user board queries @jospoortvliet +- feat: grant full owner permissions to circle members on circle-owned boards @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 4de5327367..3ca174d6b9 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -180,12 +180,15 @@ public function checkPermission(?IPermissionMapper $mapper, $id, int $permission * @param $boardId * @return bool */ - public function userIsBoardOwner($boardId, $userId = null, bool $allowDeleted = false) { + public function userIsBoardOwner($boardId, $userId = null, bool $allowDeleted = false): bool { if ($userId === null) { $userId = $this->userId; } try { $board = $this->getBoard($boardId, $allowDeleted); + if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE) { + return $this->circlesService->isUserInCircle($board->getOwner(), $userId); + } return $userId === $board->getOwner(); } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { } @@ -283,7 +286,31 @@ public function findUsers($boardId, $refresh = false) { } /** @var array */ $users = []; - if (!$this->userManager->userExists($board->getOwner())) { + if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE) { + // Board is owned by a circle: expand its members just like a circle ACL entry below. + if ($this->circlesService->isCirclesEnabled()) { + try { + $owningCircle = $this->circlesService->getCircle($board->getOwner()); + if ($owningCircle !== null) { + foreach ($owningCircle->getInheritedMembers() as $member) { + if ($member->getUserType() !== 1 || $member->getLevel() < Member::LEVEL_MEMBER) { + continue; + } + $user = $this->userManager->get($member->getUserId()); + if ($user === null) { + $this->logger->info('No user found for circle owner member ' . $member->getUserId()); + } else { + $users[(string)$member->getUserId()] = new User($member->getUserId(), $this->userManager); + } + } + } else { + $this->logger->info('No circle found for board owner ' . $board->getOwner()); + } + } catch (\Exception $e) { + $this->logger->error('Failed to expand circle owner members for board ' . $board->getId(), ['exception' => $e]); + } + } + } elseif (!$this->userManager->userExists($board->getOwner())) { $this->logger->info('No owner found for board ' . $board->getId()); } else { $users[(string)$board->getOwner()] = new User($board->getOwner(), $this->userManager); diff --git a/tests/unit/Service/PermissionServiceTest.php b/tests/unit/Service/PermissionServiceTest.php index 3099b9edd2..cc9257386b 100644 --- a/tests/unit/Service/PermissionServiceTest.php +++ b/tests/unit/Service/PermissionServiceTest.php @@ -156,6 +156,27 @@ public function testUserIsBoardOwnerNull() { $this->assertEquals(false, $this->service->userIsBoardOwner(123)); } + public function testUserIsBoardOwnerCircleMember() { + $board = new Board(); + $board->setOwner('circle-id-abc'); + $board->setOwnerType(Acl::PERMISSION_TYPE_CIRCLE); + + $this->boardMapper->expects($this->exactly(2)) + ->method('find') + ->willReturn($board); + + $this->circlesService->expects($this->exactly(2)) + ->method('isUserInCircle') + ->with('circle-id-abc', $this->anything()) + ->willReturnMap([ + ['circle-id-abc', 'admin', true], + ['circle-id-abc', 'user1', false], + ]); + + $this->assertEquals(true, $this->service->userIsBoardOwner(123, 'admin')); + $this->assertEquals(false, $this->service->userIsBoardOwner(123, 'user1')); + } + public static function dataTestUserCan() { return [ // participant permissions type From 71fd2cf10c294443596ab8abd4fd197898b141ff Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 23:34:44 +0200 Subject: [PATCH 04/27] feat: support transferring board ownership to a circle in BoardService transferBoardOwnership() gains a newOwnerType parameter (default PERMISSION_TYPE_USER, backward-compatible). Validates new owner before any DB change: userExists() for user targets, CirclesService::getCircle() for circle targets, throwing BadRequestException on failure (also fixes the silent corruption bug when transferring to a non-existent user). For circle transfers: correct ACL type used in deleteParticipantFromBoard, content remap is skipped (card owners cannot map to a circle), previous user owner receives a back-fill ACL entry unless changeContent=true. transferOwnership() (bulk OCC path) gains the same newOwnerType parameter and switches to findAllByOwner so it works for both user-owned and circle-owned boards. CirclesService added to the constructor for circle validation. Tests: transfer to circle, to missing user, to missing circle. AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + lib/Service/BoardService.php | 42 +++++++--- tests/unit/Service/BoardServiceTest.php | 102 ++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ec22c2c6..2aea2e9aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - feat: add owner_type to boards for circle ownership support @jospoortvliet - feat: resolve circle board owners in BoardMapper and include circle-owned boards in user board queries @jospoortvliet - feat: grant full owner permissions to circle members on circle-owned boards @jospoortvliet +- feat: support transferring board ownership to a circle in BoardService @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index e08b34b8c9..49f5c84ef6 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -82,6 +82,7 @@ public function __construct( private IUserManager $userManager, private ISecureRandom $random, private ConfigService $configService, + private CirclesService $circlesService, private ?string $userId, ) { } @@ -615,14 +616,34 @@ public function clone( return $this->find($newBoard->getId()); } - public function transferBoardOwnership(int $boardId, string $newOwner, bool $changeContent = false): Board { + public function transferBoardOwnership(int $boardId, string $newOwner, bool $changeContent = false, int $newOwnerType = Acl::PERMISSION_TYPE_USER): Board { $this->connection->beginTransaction(); try { $board = $this->boardMapper->find($boardId); $previousOwner = $board->getOwner(); + + // Validate the new owner before touching anything + if ($newOwnerType === Acl::PERMISSION_TYPE_CIRCLE) { + if (!$this->circlesService->isCirclesEnabled()) { + throw new BadRequestException('The Circles app is not enabled'); + } + if ($this->circlesService->getCircle($newOwner) === null) { + throw new BadRequestException('Circle not found: ' . $newOwner); + } + } else { + if (!$this->userManager->userExists($newOwner)) { + throw new BadRequestException('User not found: ' . $newOwner); + } + } + $this->clearBoardFromCache($board); - $this->aclMapper->deleteParticipantFromBoard($boardId, Acl::PERMISSION_TYPE_USER, $newOwner); - if (!$changeContent) { + // Remove new owner from ACL (avoids a duplicate entry once they become the owner) + $this->aclMapper->deleteParticipantFromBoard($boardId, $newOwnerType, $newOwner); + + // Preserve the previous owner's access via an explicit ACL entry unless the + // caller opted into a full content transfer. Skip for circle previous-owners + // because a circle ID is not a valid user participant. + if (!$changeContent && $board->getOwnerType() === Acl::PERMISSION_TYPE_USER) { try { $this->addAcl($boardId, Acl::PERMISSION_TYPE_USER, $previousOwner, true, true, true); } catch (DbException $e) { @@ -631,13 +652,15 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha } } } - $this->boardMapper->transferOwnership($previousOwner, $newOwner, $boardId); - // Optionally also change user assignments and card owner information - if ($changeContent) { + $this->boardMapper->transferOwnership($previousOwner, $newOwner, $boardId, $newOwnerType); + + // Card-content remap is only meaningful when transferring to a user, not a circle + if ($changeContent && $newOwnerType === Acl::PERMISSION_TYPE_USER) { $this->assignedUsersMapper->remapAssignedUser($boardId, $previousOwner, $newOwner); $this->cardMapper->remapCardOwner($boardId, $previousOwner, $newOwner); } + $this->connection->commit(); return $this->boardMapper->find($boardId); } catch (\Throwable $e) { @@ -646,11 +669,12 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha } } - public function transferOwnership(string $owner, string $newOwner, bool $changeContent = false): \Generator { - $boards = $this->boardMapper->findAllByUser($owner); + public function transferOwnership(string $owner, string $newOwner, bool $changeContent = false, int $newOwnerType = Acl::PERMISSION_TYPE_USER): \Generator { + // findAllByOwner uses SELECT * so it works for both user-owned and circle-owned boards + $boards = $this->boardMapper->findAllByOwner($owner); foreach ($boards as $board) { if ($board->getOwner() === $owner) { - yield $this->transferBoardOwnership($board->getId(), $newOwner, $changeContent); + yield $this->transferBoardOwnership($board->getId(), $newOwner, $changeContent, $newOwnerType); } } } diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 549021bf0c..faea13853d 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -32,6 +32,7 @@ use OC\L10N\L10N; use OC\Security\SecureRandom; use OCA\Deck\Activity\ActivityManager; +use OCA\Deck\BadRequestException; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AclMapper; use OCA\Deck\Db\Assignment; @@ -103,6 +104,8 @@ class BoardServiceTest extends TestCase { /** @var IUserManager */ private $userManager; + /** @var CirclesService|MockObject */ + private $circlesService; public function setUp(): void { parent::setUp(); @@ -125,6 +128,7 @@ public function setUp(): void { $this->boardServiceValidator = $this->createMock(BoardServiceValidator::class); $this->sessionMapper = $this->createMock(SessionMapper::class); $this->userManager = $this->createMock(IUserManager::class); + $this->circlesService = $this->createMock(CirclesService::class); $this->service = new BoardService( $this->boardMapper, @@ -151,6 +155,7 @@ public function setUp(): void { $this->userManager, $this->createMock(SecureRandom::class), $this->createMock(ConfigService::class), + $this->circlesService, $this->userId ); @@ -474,4 +479,101 @@ public function testDeleteAcl() { ->with(new AclDeletedEvent($acl)); $this->assertEquals($acl, $this->service->deleteAcl(123)); } + + public function testTransferBoardOwnershipToCircle(): void { + $board = new Board(); + $board->setId(10); + $board->setOwner('alice'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $updatedBoard = new Board(); + $updatedBoard->setId(10); + $updatedBoard->setOwner('circle-id-xyz'); + $updatedBoard->setOwnerType(Acl::PERMISSION_TYPE_CIRCLE); + + $this->connection->expects($this->once())->method('beginTransaction'); + $this->connection->expects($this->once())->method('commit'); + + $this->boardMapper->expects($this->exactly(2)) + ->method('find') + ->willReturnOnConsecutiveCalls($board, $updatedBoard); + + $this->circlesService->expects($this->once()) + ->method('isCirclesEnabled') + ->willReturn(true); + $this->circlesService->expects($this->once()) + ->method('getCircle') + ->with('circle-id-xyz') + ->willReturn($this->createMock(\OCA\Circles\Model\Circle::class)); + + $this->aclMapper->expects($this->once()) + ->method('deleteParticipantFromBoard') + ->with(10, Acl::PERMISSION_TYPE_CIRCLE, 'circle-id-xyz'); + + // Previous user owner gets an ACL entry when changeContent = false + $this->aclMapper->expects($this->once()) + ->method('findAll') + ->willReturn([]); + $this->aclMapper->expects($this->once()) + ->method('insert') + ->willReturnCallback(fn ($acl) => $acl); + + $this->boardMapper->expects($this->once()) + ->method('transferOwnership') + ->with('alice', 'circle-id-xyz', 10, Acl::PERMISSION_TYPE_CIRCLE); + + // No content remap when target is a circle + $this->assignedUsersMapper->expects($this->never())->method('remapAssignedUser'); + $this->cardMapper->expects($this->never())->method('remapCardOwner'); + + $result = $this->service->transferBoardOwnership(10, 'circle-id-xyz', false, Acl::PERMISSION_TYPE_CIRCLE); + $this->assertSame($updatedBoard, $result); + } + + public function testTransferBoardOwnershipToNonExistentUserThrows(): void { + $board = new Board(); + $board->setId(10); + $board->setOwner('alice'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $this->connection->expects($this->once())->method('beginTransaction'); + $this->connection->expects($this->once())->method('rollBack'); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->willReturn($board); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with('ghost') + ->willReturn(false); + + $this->expectException(BadRequestException::class); + $this->service->transferBoardOwnership(10, 'ghost', false, Acl::PERMISSION_TYPE_USER); + } + + public function testTransferBoardOwnershipToNonExistentCircleThrows(): void { + $board = new Board(); + $board->setId(10); + $board->setOwner('alice'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $this->connection->expects($this->once())->method('beginTransaction'); + $this->connection->expects($this->once())->method('rollBack'); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->willReturn($board); + + $this->circlesService->expects($this->once()) + ->method('isCirclesEnabled') + ->willReturn(true); + $this->circlesService->expects($this->once()) + ->method('getCircle') + ->with('bad-circle') + ->willReturn(null); + + $this->expectException(BadRequestException::class); + $this->service->transferBoardOwnership(10, 'bad-circle', false, Acl::PERMISSION_TYPE_CIRCLE); + } } From 0c8b2d9cc621ce69c6512cd12b03ea2c1b5eaf37 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 23:35:02 +0200 Subject: [PATCH 05/27] feat: accept newOwnerType in the transfer-ownership REST endpoint BoardController::transferOwner() now accepts an optional newOwnerType parameter (0=user, 7=circle). Unknown values return HTTP 400. The validated type is forwarded to BoardService::transferBoardOwnership(). AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + lib/Controller/BoardController.php | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aea2e9aa7..b396db3e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. - feat: resolve circle board owners in BoardMapper and include circle-owned boards in user board queries @jospoortvliet - feat: grant full owner permissions to circle members on circle-owned boards @jospoortvliet - feat: support transferring board ownership to a circle in BoardService @jospoortvliet +- feat: accept newOwnerType in the transfer-ownership REST endpoint @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 5c43ab7b12..3a3ae7e7c1 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -122,11 +122,13 @@ public function clone(int $boardId, bool $withCards = false, bool $withAssignmen /** * @NoAdminRequired */ - public function transferOwner(int $boardId, string $newOwner): DataResponse { + public function transferOwner(int $boardId, string $newOwner, int $newOwnerType = Acl::PERMISSION_TYPE_USER): DataResponse { + if ($newOwnerType !== Acl::PERMISSION_TYPE_USER && $newOwnerType !== Acl::PERMISSION_TYPE_CIRCLE) { + return new DataResponse(['message' => 'Invalid owner type'], HTTP::STATUS_BAD_REQUEST); + } if ($this->permissionService->userIsBoardOwner($boardId, $this->userId)) { - return new DataResponse($this->boardService->transferBoardOwnership($boardId, $newOwner), HTTP::STATUS_OK); + return new DataResponse($this->boardService->transferBoardOwnership($boardId, $newOwner, false, $newOwnerType), HTTP::STATUS_OK); } - return new DataResponse([], HTTP::STATUS_UNAUTHORIZED); } From 9d1e7ce13280c1635cef864c839b373e716156d3 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 23:35:37 +0200 Subject: [PATCH 06/27] feat: add --to-circle flag to deck:transfer-ownership OCC command New --to-circle option treats the newOwner argument as a circle ID. The command labels output accordingly, wraps the transfer in an error handler so invalid circle IDs print a clean message, and forwards PERMISSION_TYPE_CIRCLE to the service layer. Error messages are now surfaced for both single-board and bulk transfers. AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + lib/Command/TransferOwnership.php | 38 +++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b396db3e04..f1923a4f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - feat: grant full owner permissions to circle members on circle-owned boards @jospoortvliet - feat: support transferring board ownership to a circle in BoardService @jospoortvliet - feat: accept newOwnerType in the transfer-ownership REST endpoint @jospoortvliet +- feat: add --to-circle flag to deck:transfer-ownership OCC command @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/lib/Command/TransferOwnership.php b/lib/Command/TransferOwnership.php index dfa9a01c4b..fd09e65e86 100644 --- a/lib/Command/TransferOwnership.php +++ b/lib/Command/TransferOwnership.php @@ -6,6 +6,7 @@ */ namespace OCA\Deck\Command; +use OCA\Deck\Db\Acl; use OCA\Deck\Db\BoardMapper; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\PermissionService; @@ -57,6 +58,12 @@ protected function configure() { InputOption::VALUE_NONE, 'Reassign card details of the old owner to the new one' ) + ->addOption( + 'to-circle', + null, + InputOption::VALUE_NONE, + 'Treat as a circle ID instead of a user UID' + ) ; } @@ -64,8 +71,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $owner = $input->getArgument('owner'); $newOwner = $input->getArgument('newOwner'); $boardId = $input->getArgument('boardId'); - $remapAssignment = $input->getOption('remap'); + $toCircle = $input->getOption('to-circle'); + $newOwnerType = $toCircle ? Acl::PERMISSION_TYPE_CIRCLE : Acl::PERMISSION_TYPE_USER; + $newOwnerLabel = $toCircle ? "circle $newOwner" : $newOwner; $this->boardService->setUserId($owner); $this->permissionService->setUserId($owner); @@ -83,9 +92,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($boardId) { - $output->writeln('Transfer board ' . $board->getTitle() . ' from ' . $board->getOwner() . " to $newOwner"); + $output->writeln('Transfer board ' . $board->getTitle() . ' from ' . $board->getOwner() . " to $newOwnerLabel"); } else { - $output->writeln("Transfer all boards from $owner to $newOwner"); + $output->writeln("Transfer all boards from $owner to $newOwnerLabel"); } $question = new ConfirmationQuestion('Do you really want to continue? (y/n) ', false); @@ -93,16 +102,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - if ($boardId) { - $this->boardService->transferBoardOwnership($boardId, $newOwner, $remapAssignment); - $output->writeln('Board ' . $board->getTitle() . ' from ' . $board->getOwner() . " transferred to $newOwner completed"); - return 0; - } - - foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment) as $board) { - $output->writeln(' - ' . $board->getTitle() . ' transferred'); + try { + if ($boardId) { + $this->boardService->transferBoardOwnership($boardId, $newOwner, $remapAssignment, $newOwnerType); + $output->writeln('Board ' . $board->getTitle() . " transferred to $newOwnerLabel"); + return 0; + } + + foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment, $newOwnerType) as $board) { + $output->writeln(' - ' . $board->getTitle() . ' transferred'); + } + $output->writeln("All boards from $owner transferred to $newOwnerLabel"); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; } - $output->writeln("All boards from $owner to $newOwner transferred"); return 0; } From 0f5877d2efb5a9814185b333bc287a6cf5997b4d Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Wed, 29 Apr 2026 23:37:13 +0200 Subject: [PATCH 07/27] feat: show team icon for circle-owned boards and add Transfer ownership button SharingTabSidebar: when board.ownerType === 7, render a team icon instead of NcAvatar for the owner row and label it Team. The hidden Owner NcActionCheckbox is replaced by a NcActionButton labelled Transfer ownership. For user-owned boards it appears only for user ACL entries when the current user is the owner (unchanged). For circle-owned boards it appears for any ACL entry when canManage is true. Confirmation dialog and success/error messages include the target label (team name or user ID). newOwnerType is forwarded through the Vuex transferOwnership action to the PUT payload. BoardItem: guard NcAvatar with v-if board.ownerType !== 7 and show a team icon div for circle-owned boards, preventing a lookup of a circle ID as a user avatar. AI-assistant: Claude Code 2.1.80 (Claude Sonnet 4.7) Signed-off-by: Jos Poortvliet --- CHANGELOG.md | 1 + src/components/board/SharingTabSidebar.vue | 42 +++++++++++++++------- src/components/boards/BoardItem.vue | 6 +++- src/store/main.js | 3 +- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1923a4f54..5e3c0e912a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. - feat: support transferring board ownership to a circle in BoardService @jospoortvliet - feat: accept newOwnerType in the transfer-ownership REST endpoint @jospoortvliet - feat: add --to-circle flag to deck:transfer-ownership OCC command @jospoortvliet +- feat: show team icon for circle-owned boards and add Transfer ownership button in sharing sidebar @jospoortvliet - feat: update default content @luka-nextcloud [#6740](https://github.com/nextcloud/deck/pull/6740) - feat: add board import and export @luka-nextcloud [#6872](https://github.com/nextcloud/deck/pull/6872) - feat: use outline icons @luka-nextcloud [#7114](https://github.com/nextcloud/deck/pull/7114) diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 035b1f363b..013ad4c5a7 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -12,12 +12,16 @@
  • - + +
    {{ board.owner.displayname }} - + {{ t('deck', 'Board owner') }} + + {{ t('deck', 'Team') }} +
  • @@ -51,12 +55,12 @@ @change="clickManageAcl(acl)"> {{ t('deck', 'Can manage') }} - - {{ t('deck', 'Owner') }} - + @click="clickTransferOwner(acl.participant.uid || acl.participant.id, acl.type)"> + {{ t('deck', 'Transfer ownership') }} + uid === getCurrentUser().uid }, + canTransferTo() { + return (acl) => { + // For user-owned boards: only the current owner can transfer, and only to users + if (this.board.ownerType !== 7) { + return acl.type === 0 && this.isCurrentUser(this.board.owner.uid) + } + // For circle-owned boards: any circle member with manage rights can transfer to any participant + return this.canManage + } + }, formatedSharees() { const result = this.unallocatedSharees.map(item => { const res = { @@ -224,10 +238,13 @@ export default { clickDeleteAcl(acl) { this.$store.dispatch('deleteAclFromCurrentBoard', acl) }, - clickTransferOwner(newOwner) { + clickTransferOwner(newOwner, newOwnerType) { + const targetLabel = newOwnerType === 7 + ? t('deck', 'team {name}', { name: newOwner }) + : newOwner OC.dialogs.confirmDestructive( - t('deck', 'Are you sure you want to transfer the board {title} to {user}?', { title: this.board.title, user: newOwner }), - t('deck', 'Transfer the board.'), + t('deck', 'Are you sure you want to transfer the board {title} to {target}?', { title: this.board.title, target: targetLabel }), + t('deck', 'Transfer the board'), { type: OC.dialogs.YES_NO_BUTTONS, confirm: t('deck', 'Transfer'), @@ -241,12 +258,13 @@ export default { await this.$store.dispatch('transferOwnership', { boardId: this.board.id, newOwner, + newOwnerType, }) - const successMessage = t('deck', 'The board has been transferred to {user}', { user: newOwner }) + const successMessage = t('deck', 'The board has been transferred to {target}', { target: targetLabel }) showSuccess(successMessage) this.$router.push({ name: 'main' }) } catch (e) { - const errorMessage = t('deck', 'Failed to transfer the board to {user}', { user: newOwner.user }) + const errorMessage = t('deck', 'Failed to transfer the board to {target}', { target: targetLabel }) showError(errorMessage) } finally { this.isLoading = false diff --git a/src/components/boards/BoardItem.vue b/src/components/boards/BoardItem.vue index 86c174eb4c..66fb2a6ae8 100644 --- a/src/components/boards/BoardItem.vue +++ b/src/components/boards/BoardItem.vue @@ -16,10 +16,14 @@ {{ board.title }}
    - +
    Date: Thu, 30 Apr 2026 00:15:43 +0200 Subject: [PATCH 08/27] fix: use Member::TYPE_USER constant instead of magic integer 1 The circle member type check `getUserType() !== 1` uses a raw integer where a named constant is available and already imported. The existing ACL-circle expansion path already uses the named constant `Member::LEVEL_MEMBER` right next to the same condition, making the inconsistency obvious. Replace both occurrences (circle-owned board owner expansion added in this feature, and the pre-existing ACL-share expansion) with `Member::TYPE_USER`. --- lib/Service/PermissionService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 3ca174d6b9..81779a54df 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -293,7 +293,7 @@ public function findUsers($boardId, $refresh = false) { $owningCircle = $this->circlesService->getCircle($board->getOwner()); if ($owningCircle !== null) { foreach ($owningCircle->getInheritedMembers() as $member) { - if ($member->getUserType() !== 1 || $member->getLevel() < Member::LEVEL_MEMBER) { + if ($member->getUserType() !== Member::TYPE_USER || $member->getLevel() < Member::LEVEL_MEMBER) { continue; } $user = $this->userManager->get($member->getUserId()); @@ -349,7 +349,7 @@ public function findUsers($boardId, $refresh = false) { } foreach ($circle->getInheritedMembers() as $member) { - if ($member->getUserType() !== 1 || $member->getLevel() < Member::LEVEL_MEMBER) { + if ($member->getUserType() !== Member::TYPE_USER || $member->getLevel() < Member::LEVEL_MEMBER) { // deck currently only supports user members in circles continue; } From 9f1864c6b171994b055c536c0253a0f0cb53c7db Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 00:16:19 +0200 Subject: [PATCH 09/27] fix: scope user-owner queries to owner_type = PERMISSION_TYPE_USER Now that owner_type distinguishes user owners (0) from circle owners (7), queries that look up boards by user ID should explicitly exclude circle-owned boards from the user-owner path. Without this guard, findBoardIds and findAllByUser would accidentally return a circle-owned board if the circle's single ID happened to match the user ID string - impossible today (circle IDs are UUIDs, user IDs are logins) but semantically wrong and a latent bug. Being explicit also makes the intent clear to future readers. --- lib/Db/BoardMapper.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index dfdd5a84af..41ade67f2a 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -86,6 +86,7 @@ public function findBoardIds(string $userId): array { ->from($this->getTableName(), 'b') ->where($qb->expr()->andX( $qb->expr()->eq('owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)), + $qb->expr()->eq('b.owner_type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_USER, IQueryBuilder::PARAM_INT)), )); $result = $qb->executeQuery(); $ownerBoards = array_map(function (string $id) { @@ -213,7 +214,8 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset = // this does not work in MySQL/PostgreSQL //->selectAlias('0', 'shared') ->from('deck_boards', 'b') - ->where($qb->expr()->eq('owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))); + ->where($qb->expr()->eq('owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('b.owner_type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_USER, IQueryBuilder::PARAM_INT))); if (!$includeArchived) { $qb->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); From 45de51c134c02f062c9c89e531b2a292e7a272e8 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 00:16:52 +0200 Subject: [PATCH 10/27] perf: cache getUserCircles result per user within a request findAllForUser now calls both findAllByCircles() and findAllByCircleOwner(), each of which independently calls getUserCircles(). Without caching, every board-list request launches two Circles API sessions (getFederatedUser, startSession, getCircles) for the same user in the same PHP process. Add a $userCirclesCache keyed by userId, mirroring the existing $userCircleCache already used by isUserInCircle. The cache is per-object (per-request in a normal Nextcloud HTTP context), so stale data is not a concern. --- lib/Service/CirclesService.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/Service/CirclesService.php b/lib/Service/CirclesService.php index 96af9c155c..05eac6f59c 100644 --- a/lib/Service/CirclesService.php +++ b/lib/Service/CirclesService.php @@ -27,6 +27,8 @@ class CirclesService { private bool $circlesEnabled; private $userCircleCache = []; + /** @var array */ + private array $userCirclesCache = []; public function __construct(IAppManager $appManager) { $this->circlesEnabled = $appManager->isEnabledForUser('circles'); @@ -93,15 +95,21 @@ public function getUserCircles(string $userId): array { return []; } + if (isset($this->userCirclesCache[$userId])) { + return $this->userCirclesCache[$userId]; + } + try { $circlesManager = Server::get(CirclesManager::class); $federatedUser = $circlesManager->getFederatedUser($userId, Member::TYPE_USER); $circlesManager->startSession($federatedUser); $probe = new CircleProbe(); $probe->mustBeMember(); - return array_map(function (Circle $circle) { + $circles = array_map(function (Circle $circle) { return $circle->getSingleId(); }, $circlesManager->probeCircles($probe)); + $this->userCirclesCache[$userId] = $circles; + return $circles; } catch (Throwable $e) { } return []; From d3c46801b2c6063aafe9aff035491f2f8e04169c Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 00:18:51 +0200 Subject: [PATCH 11/27] refactor: centralise ACL permission type constants in src/helpers/constants.js The feature introduced comparisons like `ownerType !== 7`, `ownerType === 7`, and `newOwnerType === 7` in three different files (SharingTabSidebar.vue, BoardItem.vue, main.js), spreading the magic number 7 (PERMISSION_TYPE_CIRCLE) through the frontend. The same file already defined SOURCE_TO_SHARE_TYPE with `circles: 7` locally, duplicating the constant yet again. Introduce src/helpers/constants.js that exports named constants mirroring the PHP Acl::PERMISSION_TYPE_* values, and move SOURCE_TO_SHARE_TYPE there as well. All three files now import and use the named constants; the local SOURCE_TO_SHARE_TYPE definition in SharingTabSidebar.vue is removed. --- src/components/board/SharingTabSidebar.vue | 24 +++++++++------------ src/components/boards/BoardItem.vue | 8 ++++++- src/helpers/constants.js | 25 ++++++++++++++++++++++ src/store/main.js | 3 ++- 4 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 src/helpers/constants.js diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 013ad4c5a7..d01c82bedf 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -12,14 +12,14 @@
    • - +
      {{ board.owner.displayname }} - + {{ t('deck', 'Board owner') }} - + {{ t('deck', 'Team') }} @@ -89,14 +89,7 @@ import { getCurrentUser } from '@nextcloud/auth' import { showError, showSuccess } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import debounce from 'lodash/debounce.js' -const SOURCE_TO_SHARE_TYPE = { - users: 0, - groups: 1, - emails: 4, - remotes: 6, - circles: 7, - teams: 7, -} +import { PERMISSION_TYPE_CIRCLE, PERMISSION_TYPE_USER, SOURCE_TO_SHARE_TYPE } from '../../helpers/constants.js' export default { name: 'SharingTabSidebar', @@ -123,6 +116,9 @@ export default { addAclForAPI: null, newOwner: null, projectsEnabled: loadState('core', 'projects_enabled', false), + // Expose ACL type constants to the template + PERMISSION_TYPE_CIRCLE, + PERMISSION_TYPE_USER, } }, computed: { @@ -140,8 +136,8 @@ export default { canTransferTo() { return (acl) => { // For user-owned boards: only the current owner can transfer, and only to users - if (this.board.ownerType !== 7) { - return acl.type === 0 && this.isCurrentUser(this.board.owner.uid) + if (this.board.ownerType !== PERMISSION_TYPE_CIRCLE) { + return acl.type === PERMISSION_TYPE_USER && this.isCurrentUser(this.board.owner.uid) } // For circle-owned boards: any circle member with manage rights can transfer to any participant return this.canManage @@ -239,7 +235,7 @@ export default { this.$store.dispatch('deleteAclFromCurrentBoard', acl) }, clickTransferOwner(newOwner, newOwnerType) { - const targetLabel = newOwnerType === 7 + const targetLabel = newOwnerType === PERMISSION_TYPE_CIRCLE ? t('deck', 'team {name}', { name: newOwner }) : newOwner OC.dialogs.confirmDestructive( diff --git a/src/components/boards/BoardItem.vue b/src/components/boards/BoardItem.vue index 66fb2a6ae8..f355a28ef3 100644 --- a/src/components/boards/BoardItem.vue +++ b/src/components/boards/BoardItem.vue @@ -16,7 +16,7 @@ {{ board.title }}
      - import { NcAvatar } from '@nextcloud/vue' +import { PERMISSION_TYPE_CIRCLE } from '../../helpers/constants.js' export default { name: 'BoardItem', @@ -50,6 +51,11 @@ export default { default: () => { return {} }, }, }, + data() { + return { + PERMISSION_TYPE_CIRCLE, + } + }, computed: { routeTo() { return { diff --git a/src/helpers/constants.js b/src/helpers/constants.js new file mode 100644 index 0000000000..620b51cf8d --- /dev/null +++ b/src/helpers/constants.js @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * ACL / owner permission types – mirror Acl::PERMISSION_TYPE_* PHP constants. + * Keep in sync with lib/Db/Acl.php. + */ +export const PERMISSION_TYPE_USER = 0 +export const PERMISSION_TYPE_GROUP = 1 +export const PERMISSION_TYPE_REMOTE = 6 +export const PERMISSION_TYPE_CIRCLE = 7 + +/** + * Map from the Nextcloud sharee-picker source identifier to the board ACL type. + */ +export const SOURCE_TO_SHARE_TYPE = { + users: PERMISSION_TYPE_USER, + groups: PERMISSION_TYPE_GROUP, + emails: 4, + remotes: PERMISSION_TYPE_REMOTE, + circles: PERMISSION_TYPE_CIRCLE, + teams: PERMISSION_TYPE_CIRCLE, +} diff --git a/src/store/main.js b/src/store/main.js index 4bdecf4503..b8963d24bf 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -11,6 +11,7 @@ import Vuex from 'vuex' import axios from '@nextcloud/axios' import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { BoardApi } from '../services/BoardApi.js' +import { PERMISSION_TYPE_USER } from '../helpers/constants.js' import stackModuleFactory from './stack.js' import cardModuleFactory from './card.js' import comment from './comment.js' @@ -524,7 +525,7 @@ export default function storeFactory() { dispatch('loadBoardById', acl.boardId) }) }, - async transferOwnership({ commit }, { boardId, newOwner, newOwnerType = 0 }) { + async transferOwnership({ commit }, { boardId, newOwner, newOwnerType = PERMISSION_TYPE_USER }) { await axios.put(generateUrl(`apps/deck/boards/${boardId}/transferOwner`), { newOwner, newOwnerType, From 719461dc7c4196d75c0dfb6b88ee845b38d08eeb Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 00:19:12 +0200 Subject: [PATCH 12/27] refactor: remove redundant owner equality check in transferOwnership findAllByOwner() already queries WHERE owner = $owner, so every board in the returned collection is guaranteed to have getOwner() === $owner. The inner guard is always true and adds noise without benefit. --- lib/Service/BoardService.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 49f5c84ef6..700d8e1aa2 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -670,12 +670,9 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha } public function transferOwnership(string $owner, string $newOwner, bool $changeContent = false, int $newOwnerType = Acl::PERMISSION_TYPE_USER): \Generator { - // findAllByOwner uses SELECT * so it works for both user-owned and circle-owned boards $boards = $this->boardMapper->findAllByOwner($owner); foreach ($boards as $board) { - if ($board->getOwner() === $owner) { - yield $this->transferBoardOwnership($board->getId(), $newOwner, $changeContent, $newOwnerType); - } + yield $this->transferBoardOwnership($board->getId(), $newOwner, $changeContent, $newOwnerType); } } From ba9603c56f9e3504e667df10c6a73de97db41ebc Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 00:19:33 +0200 Subject: [PATCH 13/27] chore: drop explicit unsigned=false from migration column definition Doctrine DBAL defaults to unsigned=false for integer columns; spelling it out adds noise without conveying intent and may imply the choice was deliberate rather than incidental. --- lib/Migration/Version11002Date20260429000000.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Migration/Version11002Date20260429000000.php b/lib/Migration/Version11002Date20260429000000.php index 972687399a..a1b2d4ab73 100644 --- a/lib/Migration/Version11002Date20260429000000.php +++ b/lib/Migration/Version11002Date20260429000000.php @@ -31,7 +31,6 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->addColumn('owner_type', 'smallint', [ 'notnull' => true, 'default' => 0, - 'unsigned' => false, ]); } } From ae28e40f64b72f5ebc776c6cb9ec744f72fbc740 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 08:39:48 +0200 Subject: [PATCH 14/27] Fix issue in SharingTabSidebar & add migration back Signed-off-by: Jos Poortvliet --- lib/Migration/Version11002Date20260429000000.php | 1 + src/components/board/SharingTabSidebar.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Migration/Version11002Date20260429000000.php b/lib/Migration/Version11002Date20260429000000.php index a1b2d4ab73..972687399a 100644 --- a/lib/Migration/Version11002Date20260429000000.php +++ b/lib/Migration/Version11002Date20260429000000.php @@ -31,6 +31,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->addColumn('owner_type', 'smallint', [ 'notnull' => true, 'default' => 0, + 'unsigned' => false, ]); } } diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index d01c82bedf..8969ec656c 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -150,7 +150,7 @@ export default { displayName: item.displayname || item.name || item.label || item.id, user: item.id, subname: item.shareWithDisplayNameUnique || item.subline || item.id, // NcSelectUser does its own pattern matching to filter things out - type: SOURCE_TO_SHARE_TYPE[item.source] + type: SOURCE_TO_SHARE_TYPE[item.source], } return res }).slice(0, 10) From c3867c683c39d01a01cca4854c37d3fe0aaf32ab Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Thu, 30 Apr 2026 09:15:00 +0200 Subject: [PATCH 15/27] fix: show Transfer ownership button for circle/team ACL entries canTransferTo was guarded with acl.type === PERMISSION_TYPE_USER, which silently excluded circle entries (type=7) from ever seeing the Transfer ownership button. The board backend already accepts PERMISSION_TYPE_CIRCLE as a valid newOwnerType, so the UI restriction had no purpose. Split the eligibility check into two separate concerns: 1. canBeOwnershipTarget: only user and circle participants are valid new owners (groups, remotes, etc. are not). 2. Permission to perform the transfer: current user must be the board owner (user-owned board) or have manage rights (circle-owned board). This means the Transfer ownership button now appears in the ... menu for both user and team/circle participants, as long as the current user has the right to initiate the transfer. --- src/components/board/SharingTabSidebar.vue | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 8969ec656c..ce06fee0a1 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -135,11 +135,16 @@ export default { }, canTransferTo() { return (acl) => { - // For user-owned boards: only the current owner can transfer, and only to users + const canBeOwnershipTarget = acl.type === PERMISSION_TYPE_USER || acl.type === PERMISSION_TYPE_CIRCLE + if (!canBeOwnershipTarget) { + return false + } + + // For user-owned boards: only the current owner can transfer if (this.board.ownerType !== PERMISSION_TYPE_CIRCLE) { - return acl.type === PERMISSION_TYPE_USER && this.isCurrentUser(this.board.owner.uid) + return this.isCurrentUser(this.board.owner.uid) } - // For circle-owned boards: any circle member with manage rights can transfer to any participant + // For circle-owned boards: any circle member with manage rights can transfer return this.canManage } }, From c7901538a3f3e2032069aeeadbd4c564ba5611ec Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Fri, 1 May 2026 11:46:41 +0200 Subject: [PATCH 16/27] add the package lot that was missed earlier, and update .gitignore to not include the php-cs-fixer cache file --- .gitignore | 1 + package-lock.json | 134 ---------------------------------------------- 2 files changed, 1 insertion(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index e8034c743f..087b37fd2b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ tests/integration/composer.lock tests/.phpunit.result.cache vendor/ .php_cs.cache +.php-cs-fixer.cache \.idea/ settings.json diff --git a/package-lock.json b/package-lock.json index 271b222139..a9421bae5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4750,34 +4750,6 @@ "@vue/shared": "3.5.30" } }, - "node_modules/@nextcloud/password-confirmation/node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@nextcloud/password-confirmation/node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "rfdc": "^1.4.1" - } - }, "node_modules/@nextcloud/password-confirmation/node_modules/@vue/server-renderer": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", @@ -4973,14 +4945,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nextcloud/password-confirmation/node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@nextcloud/password-confirmation/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -4993,40 +4957,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@nextcloud/password-confirmation/node_modules/pinia": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", - "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-api": "^7.7.7" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.5.0", - "vue": "^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@nextcloud/password-confirmation/node_modules/pinia/node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, "node_modules/@nextcloud/password-confirmation/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -9845,23 +9775,6 @@ "dev": true, "peer": true }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/core-js": { "version": "2.6.9", "hasInstallScript": true, @@ -15470,20 +15383,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/is-whitespace": { "version": "0.3.0", "dev": true, @@ -19114,14 +19013,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -22694,17 +22585,6 @@ "wbuf": "^1.7.3" } }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/spec-change": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/spec-change/-/spec-change-1.11.21.tgz", @@ -23865,20 +23745,6 @@ "node": ">=18" } }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/superstruct": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", From 356172b2227eb1aa6c71f29b8ef4378c7aab6e72 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Fri, 1 May 2026 12:35:57 +0200 Subject: [PATCH 17/27] feat: support team-aware ownership transfer and notifications - allow transfer ownership to team targets from UI and OCC with team-first wording - rename OCC flag to --to-team (drop --to-circle) and auto-detect team IDs safely - validate transfer target up front in transferOwnership to fail fast on invalid users/teams - show team display names (not circle IDs) in transfer confirmations and OCC output - extend board share notifications to team members - extend card assignment notifications to team members and add team-specific notification text - mark team assignment notifications as processed on unassign - keep internal backend semantics based on circle IDs, with clarifying comments --- lib/Command/TransferOwnership.php | 57 ++++++++- lib/Notification/NotificationHelper.php | 139 ++++++++++++++++++++- lib/Notification/Notifier.php | 86 +++++++++---- lib/Service/AssignmentService.php | 8 +- lib/Service/BoardService.php | 32 +++-- src/components/board/SharingTabSidebar.vue | 6 +- 6 files changed, 270 insertions(+), 58 deletions(-) diff --git a/lib/Command/TransferOwnership.php b/lib/Command/TransferOwnership.php index fd09e65e86..a7da2c09d6 100644 --- a/lib/Command/TransferOwnership.php +++ b/lib/Command/TransferOwnership.php @@ -9,7 +9,9 @@ use OCA\Deck\Db\Acl; use OCA\Deck\Db\BoardMapper; use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\CirclesService; use OCA\Deck\Service\PermissionService; +use OCP\IUserManager; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; @@ -23,14 +25,18 @@ final class TransferOwnership extends Command { protected $boardMapper; protected $permissionService; protected $questionHelper; + protected $userManager; + protected $circlesService; - public function __construct(BoardService $boardService, BoardMapper $boardMapper, PermissionService $permissionService, QuestionHelper $questionHelper) { + public function __construct(BoardService $boardService, BoardMapper $boardMapper, PermissionService $permissionService, QuestionHelper $questionHelper, IUserManager $userManager, CirclesService $circlesService) { parent::__construct(); $this->boardService = $boardService; $this->boardMapper = $boardMapper; $this->permissionService = $permissionService; $this->questionHelper = $questionHelper; + $this->userManager = $userManager; + $this->circlesService = $circlesService; } protected function configure() { @@ -59,10 +65,10 @@ protected function configure() { 'Reassign card details of the old owner to the new one' ) ->addOption( - 'to-circle', + 'to-team', null, InputOption::VALUE_NONE, - 'Treat as a circle ID instead of a user UID' + 'Treat as a team ID (internally stored as a circle ID) instead of a user UID' ) ; } @@ -72,9 +78,48 @@ protected function execute(InputInterface $input, OutputInterface $output): int $newOwner = $input->getArgument('newOwner'); $boardId = $input->getArgument('boardId'); $remapAssignment = $input->getOption('remap'); - $toCircle = $input->getOption('to-circle'); - $newOwnerType = $toCircle ? Acl::PERMISSION_TYPE_CIRCLE : Acl::PERMISSION_TYPE_USER; - $newOwnerLabel = $toCircle ? "circle $newOwner" : $newOwner; + $toTeam = $input->getOption('to-team'); + $newOwnerType = Acl::PERMISSION_TYPE_USER; + $teamDisplayName = null; + if ($toTeam) { + $newOwnerType = Acl::PERMISSION_TYPE_CIRCLE; + if ($this->circlesService->isCirclesEnabled()) { + try { + $circle = $this->circlesService->getCircle($newOwner); + if ($circle !== null) { + $teamDisplayName = $circle->getDisplayName(); + } + } catch (\Throwable $e) { + $teamDisplayName = null; + } + } + } else { + $userExists = $this->userManager->userExists($newOwner); + $circleExists = false; + $circle = null; + if ($this->circlesService->isCirclesEnabled()) { + try { + $circle = $this->circlesService->getCircle($newOwner); + $circleExists = $circle !== null; + if ($circle !== null) { + $teamDisplayName = $circle->getDisplayName(); + } + } catch (\Throwable $e) { + $circleExists = false; + } + } + + if ($userExists && $circleExists) { + $output->writeln('Ambiguous target: ' . $newOwner . ' matches both a user and a team (circle ID). Use --to-team to transfer to the team.'); + return 1; + } + + if ($circleExists && !$userExists) { + $newOwnerType = Acl::PERMISSION_TYPE_CIRCLE; + $output->writeln('Detected team target: treating ' . $newOwner . ' as team ' . ($teamDisplayName ?: $newOwner) . '.'); + } + } + $newOwnerLabel = $newOwnerType === Acl::PERMISSION_TYPE_CIRCLE ? 'team ' . ($teamDisplayName ?: $newOwner) : $newOwner; $this->boardService->setUserId($owner); $this->permissionService->setUserId($owner); diff --git a/lib/Notification/NotificationHelper.php b/lib/Notification/NotificationHelper.php index 0813111334..b20e053b32 100644 --- a/lib/Notification/NotificationHelper.php +++ b/lib/Notification/NotificationHelper.php @@ -11,6 +11,7 @@ use DateTime; use Exception; +use OCA\Circles\Model\Member; use OCA\Deck\AppInfo\Application; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AssignmentMapper; @@ -20,6 +21,7 @@ use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\User; use OCA\Deck\Service\ConfigService; +use OCA\Deck\Service\CirclesService; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -39,6 +41,7 @@ public function __construct( protected readonly BoardMapper $boardMapper, protected readonly AssignmentMapper $assignmentMapper, protected readonly PermissionService $permissionService, + protected readonly CirclesService $circlesService, protected readonly IConfig $config, protected readonly IManager $notificationManager, protected readonly IGroupManager $groupManager, @@ -105,6 +108,45 @@ public function markDuedateAsRead(Card $card): void { } public function sendCardAssigned(Card $card, string $userId): void { + $this->sendCardAssignedByType($card, $userId, Acl::PERMISSION_TYPE_USER); + } + + public function sendCardAssignedByType(Card $card, string $participantId, int $type): void { + if ($type === Acl::PERMISSION_TYPE_CIRCLE) { + if (!$this->circlesService->isCirclesEnabled()) { + return; + } + + $circle = $this->circlesService->getCircle($participantId); + if ($circle === null) { + return; + } + + $teamName = $circle->getDisplayName() ?: $participantId; + $members = array_filter($circle->getInheritedMembers(), static function (Member $member) { + return $member->getUserType() === Member::TYPE_USER; + }); + foreach ($members as $member) { + $userId = $member->getUserId(); + if ($userId === $this->userId) { + continue; + } + + $this->sendCardAssignedToUser($card, $userId, $teamName); + } + return; + } + + foreach ($this->resolveParticipantUserIds($participantId, $type) as $userId) { + if ($userId === $this->userId) { + continue; + } + + $this->sendCardAssignedToUser($card, $userId); + } + } + + private function sendCardAssignedToUser(Card $card, string $userId, ?string $teamName = null): void { $boardId = $this->cardMapper->findBoardId($card->getId()); try { $board = $this->getBoard($boardId); @@ -112,21 +154,41 @@ public function sendCardAssigned(Card $card, string $userId): void { return; } + $subjectParams = [ + $card->getTitle(), + $board->getTitle(), + $this->userId, + ]; + if ($teamName !== null) { + $subjectParams[] = 'team'; + $subjectParams[] = $teamName; + } + $notification = $this->notificationManager->createNotification(); $notification ->setApp('deck') ->setUser($userId) ->setDateTime(new DateTime()) ->setObject('card', (string)$card->getId()) - ->setSubject('card-assigned', [ - $card->getTitle(), - $board->getTitle(), - $this->userId - ]); + ->setSubject('card-assigned', $subjectParams); $this->notificationManager->notify($notification); } public function markCardAssignedAsRead(Card $card, string $userId): void { + $this->markCardAssignedAsReadByType($card, $userId, Acl::PERMISSION_TYPE_USER); + } + + public function markCardAssignedAsReadByType(Card $card, string $participantId, int $type): void { + foreach ($this->resolveParticipantUserIds($participantId, $type) as $userId) { + if ($userId === $this->userId) { + continue; + } + + $this->markCardAssignedAsReadForUser($card, $userId); + } + } + + private function markCardAssignedAsReadForUser(Card $card, string $userId): void { $notification = $this->notificationManager->createNotification(); $notification ->setApp('deck') @@ -137,7 +199,45 @@ public function markCardAssignedAsRead(Card $card, string $userId): void { } /** - * Send notifications that a board was shared with a user/group + * Resolve assignment participant ids to concrete user ids for notifications. + * Teams are represented by circles internally. + * + * @return string[] + */ + private function resolveParticipantUserIds(string $participantId, int $type): array { + if ($type === Acl::PERMISSION_TYPE_USER) { + return [$participantId]; + } + + if ($type === Acl::PERMISSION_TYPE_GROUP) { + $group = $this->groupManager->get($participantId); + if ($group === null) { + return []; + } + return array_map(static fn ($user) => $user->getUID(), $group->getUsers()); + } + + if ($type === Acl::PERMISSION_TYPE_CIRCLE) { + if (!$this->circlesService->isCirclesEnabled()) { + return []; + } + + $circle = $this->circlesService->getCircle($participantId); + if ($circle === null) { + return []; + } + + $members = array_filter($circle->getInheritedMembers(), static function (Member $member) { + return $member->getUserType() === Member::TYPE_USER; + }); + return array_values(array_unique(array_map(static fn (Member $member) => $member->getUserId(), $members))); + } + + return []; + } + + /** + * Send notifications that a board was shared with a user/group/team. */ public function sendBoardShared(int $boardId, Acl $acl, bool $markAsRead = false): void { try { @@ -173,6 +273,33 @@ public function sendBoardShared(int $boardId, Acl $acl, bool $markAsRead = false } } } + if ($acl->getType() === Acl::PERMISSION_TYPE_CIRCLE) { + if (!$this->circlesService->isCirclesEnabled()) { + return; + } + + $circle = $this->circlesService->getCircle($acl->getParticipant()); + if ($circle === null) { + return; + } + + $members = array_filter($circle->getInheritedMembers(), static function (Member $member) { + return $member->getUserType() === Member::TYPE_USER; + }); + foreach ($members as $member) { + $userId = $member->getUserId(); + if ($userId === $this->userId) { + continue; + } + $notification = $this->generateBoardShared($board, $userId); + if ($markAsRead) { + $this->notificationManager->markProcessed($notification); + } else { + $notification->setDateTime(new DateTime()); + $this->notificationManager->notify($notification); + } + } + } } public function sendMention(IComment $comment): void { diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 87ee25941e..5dc6a05898 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -79,33 +79,65 @@ public function prepare(INotification $notification, string $languageCode): INot } else { $dn = $params[2]; } - $notification->setParsedSubject( - $l->t('The card "%s" on "%s" has been assigned to you by %s.', [$params[0], $params[1], $dn]) - ); - $notification->setRichSubject( - $l->t('{user} has assigned the card {deck-card} on {deck-board} to you.'), - [ - 'deck-card' => [ - 'type' => 'deck-card', - 'id' => (string)$cardId, - 'name' => $params[0], - 'boardname' => (string)$params[1], - 'stackname' => $stack->getTitle(), - 'link' => $this->getCardUrl($boardId, $cardId), - ], - 'deck-board' => [ - 'type' => 'deck-board', - 'id' => (string)$boardId, - 'name' => (string)$params[1], - 'link' => $this->getBoardUrl($boardId), - ], - 'user' => [ - 'type' => 'user', - 'id' => (string)$params[2], - 'name' => $dn, - ] - ] - ); + $isTeamAssignment = isset($params[3]) && $params[3] === 'team'; + if ($isTeamAssignment) { + $teamName = $params[4] ?? ''; + $notification->setParsedSubject( + $l->t('The card "%1$s" on "%2$s" has been assigned to team "%3$s" by %4$s. You are receiving this because you are a member of that team.', [$params[0], $params[1], $teamName, $dn]) + ); + $notification->setRichSubject( + $l->t('{user} has assigned the card {deck-card} on {deck-board} to a team you are a member of.'), + [ + 'deck-card' => [ + 'type' => 'deck-card', + 'id' => (string)$cardId, + 'name' => $params[0], + 'boardname' => (string)$params[1], + 'stackname' => $stack->getTitle(), + 'link' => $this->getCardUrl($boardId, $cardId), + ], + 'deck-board' => [ + 'type' => 'deck-board', + 'id' => (string)$boardId, + 'name' => (string)$params[1], + 'link' => $this->getBoardUrl($boardId), + ], + 'user' => [ + 'type' => 'user', + 'id' => (string)$params[2], + 'name' => $dn, + ] + ] + ); + } else { + $notification->setParsedSubject( + $l->t('The card "%s" on "%s" has been assigned to you by %s.', [$params[0], $params[1], $dn]) + ); + $notification->setRichSubject( + $l->t('{user} has assigned the card {deck-card} on {deck-board} to you.'), + [ + 'deck-card' => [ + 'type' => 'deck-card', + 'id' => (string)$cardId, + 'name' => $params[0], + 'boardname' => (string)$params[1], + 'stackname' => $stack->getTitle(), + 'link' => $this->getCardUrl($boardId, $cardId), + ], + 'deck-board' => [ + 'type' => 'deck-board', + 'id' => (string)$boardId, + 'name' => (string)$params[1], + 'link' => $this->getBoardUrl($boardId), + ], + 'user' => [ + 'type' => 'user', + 'id' => (string)$params[2], + 'name' => $dn, + ] + ] + ); + } $notification->setLink($this->getCardUrl($boardId, $cardId)); break; case 'card-overdue': diff --git a/lib/Service/AssignmentService.php b/lib/Service/AssignmentService.php index dd8d8f17a0..81be618ef8 100644 --- a/lib/Service/AssignmentService.php +++ b/lib/Service/AssignmentService.php @@ -75,8 +75,8 @@ public function assignUser(int $cardId, string $userId, int $type = Assignment:: } - if ($type === Assignment::TYPE_USER && $userId !== $this->userId) { - $this->notificationHelper->sendCardAssigned($card, $userId); + if ($userId !== $this->userId) { + $this->notificationHelper->sendCardAssignedByType($card, $userId, $type); } $assignment = new Assignment(); @@ -109,8 +109,8 @@ public function unassignUser(int $cardId, string $userId, int $type = 0): Assign $assignment = $this->assignedUsersMapper->delete($assignment); $card = $this->cardMapper->find($cardId); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_UNASSIGN, ['assigneduser' => $userId]); - if ($type === Assignment::TYPE_USER && $userId !== $this->userId) { - $this->notificationHelper->markCardAssignedAsRead($card, $userId); + if ($userId !== $this->userId) { + $this->notificationHelper->markCardAssignedAsReadByType($card, $userId, $type); } $this->changeHelper->cardChanged($cardId); diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 700d8e1aa2..d46abfb9d6 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -623,18 +623,7 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha $previousOwner = $board->getOwner(); // Validate the new owner before touching anything - if ($newOwnerType === Acl::PERMISSION_TYPE_CIRCLE) { - if (!$this->circlesService->isCirclesEnabled()) { - throw new BadRequestException('The Circles app is not enabled'); - } - if ($this->circlesService->getCircle($newOwner) === null) { - throw new BadRequestException('Circle not found: ' . $newOwner); - } - } else { - if (!$this->userManager->userExists($newOwner)) { - throw new BadRequestException('User not found: ' . $newOwner); - } - } + $this->validateTransferTarget($newOwner, $newOwnerType); $this->clearBoardFromCache($board); // Remove new owner from ACL (avoids a duplicate entry once they become the owner) @@ -670,12 +659,31 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha } public function transferOwnership(string $owner, string $newOwner, bool $changeContent = false, int $newOwnerType = Acl::PERMISSION_TYPE_USER): \Generator { + // Validate once up front so invalid targets fail even if no boards match. + $this->validateTransferTarget($newOwner, $newOwnerType); $boards = $this->boardMapper->findAllByOwner($owner); foreach ($boards as $board) { yield $this->transferBoardOwnership($board->getId(), $newOwner, $changeContent, $newOwnerType); } } + private function validateTransferTarget(string $newOwner, int $newOwnerType): void { + // Teams are represented by Circles internally (PERMISSION_TYPE_CIRCLE + circle ID). + if ($newOwnerType === Acl::PERMISSION_TYPE_CIRCLE) { + if (!$this->circlesService->isCirclesEnabled()) { + throw new BadRequestException('The Circles app is not enabled'); + } + if ($this->circlesService->getCircle($newOwner) === null) { + throw new BadRequestException('Circle not found: ' . $newOwner); + } + return; + } + + if (!$this->userManager->userExists($newOwner)) { + throw new BadRequestException('User not found: ' . $newOwner); + } + } + /** * @throws DoesNotExistException * @throws NoPermissionException diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index ce06fee0a1..2649b2b67d 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -58,7 +58,7 @@ + @click="clickTransferOwner(acl.participant.uid || acl.participant.id, acl.type, acl.participant.displayname || acl.participant.id || acl.participant.uid)"> {{ t('deck', 'Transfer ownership') }} Date: Sat, 2 May 2026 14:02:44 +0200 Subject: [PATCH 18/27] * check via canTransferTo if the user is in a circle that has ownership * Show that removing a user who has access rights through a group or team membership only removes these ACL's, not their entire access. Comes with tests. * Add transfer-ownership-to-self button for user who is in a circle that owns a board Signed-off-by: Jos Poortvliet --- lib/Command/TransferOwnership.php | 4 +- lib/Db/Acl.php | 6 + lib/Service/BoardService.php | 46 +++++- lib/Service/PermissionService.php | 3 +- src/components/board/SharingTabSidebar.vue | 23 ++- src/store/main.js | 3 + tests/unit/Service/BoardServiceTest.php | 173 +++++++++++++++++++++ 7 files changed, 248 insertions(+), 10 deletions(-) diff --git a/lib/Command/TransferOwnership.php b/lib/Command/TransferOwnership.php index a7da2c09d6..38b20e78ed 100644 --- a/lib/Command/TransferOwnership.php +++ b/lib/Command/TransferOwnership.php @@ -46,12 +46,12 @@ protected function configure() { ->addArgument( 'owner', InputArgument::REQUIRED, - 'Owner uid' + 'Owner uid or Team (circle) ID to transfer from' ) ->addArgument( 'newOwner', InputArgument::REQUIRED, - 'New owner uid' + 'New owner uid or Team (circle) ID to transfer to' ) ->addArgument( 'boardId', diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 735f9a19fc..8f7fe660a9 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -21,6 +21,8 @@ * @method void setOwner(int $owner) * @method void setToken(string $token) * @method string getToken() + * @method bool isRetainsAccessViaMembership() + * @method void setRetainsAccessViaMembership(bool $retainsAccessViaMembership) * */ class Acl extends RelationalEntity { @@ -28,6 +30,7 @@ class Acl extends RelationalEntity { public const PERMISSION_EDIT = 1; public const PERMISSION_SHARE = 2; public const PERMISSION_MANAGE = 3; + public const PERMISSION_OWNER = 4; public const PERMISSION_TYPE_USER = 0; public const PERMISSION_TYPE_GROUP = 1; @@ -41,6 +44,7 @@ class Acl extends RelationalEntity { protected $permissionShare = false; protected $permissionManage = false; protected $owner = false; + protected $retainsAccessViaMembership = false; protected $token = null; public function __construct() { @@ -51,8 +55,10 @@ public function __construct() { $this->addType('permissionManage', 'boolean'); $this->addType('type', 'integer'); $this->addType('owner', 'boolean'); + $this->addType('retainsAccessViaMembership', 'boolean'); $this->addType('token', 'string'); $this->addRelation('owner'); + $this->addRelation('retainsAccessViaMembership'); $this->addResolvable('participant'); } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index d46abfb9d6..8f8aabe24f 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -222,7 +222,8 @@ public function create(string $title, string $userId, string $color): Board { 'PERMISSION_READ' => $permissions[Acl::PERMISSION_READ] ?? false, 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, - 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false + 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, + 'PERMISSION_OWNER' => $permissions[Acl::PERMISSION_OWNER] ?? false, ]); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $board, ActivityManager::SUBJECT_BOARD_CREATE, [], $userId); $this->changeHelper->boardChanged($board->getId()); @@ -573,8 +574,9 @@ public function clone( 'PERMISSION_READ' => $permissions[Acl::PERMISSION_READ] ?? false, 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, - 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false - ]); + 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, + 'PERMISSION_OWNER' => $permissions[Acl::PERMISSION_OWNER] ?? false, + ]); $this->boardMapper->insert($newBoard); foreach ($this->aclMapper->findAll($board->getId()) as $acl) { @@ -711,6 +713,7 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { foreach ($board->getAcl() as &$acl) { $this->boardMapper->mapAcl($acl); } + $this->annotateAclRetainedAccess($board); } $permissions = $this->permissionService->matchPermissions($board); @@ -718,7 +721,8 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { 'PERMISSION_READ' => $permissions[Acl::PERMISSION_READ] ?? false, 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, - 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false + 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, + 'PERMISSION_OWNER' => $permissions[Acl::PERMISSION_OWNER] ?? false, ]); if ($fullDetails) { @@ -740,6 +744,40 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { return $boards; } + private function annotateAclRetainedAccess(Board $board): void { + $acls = $board->getAcl() ?? []; + foreach ($acls as $acl) { + if ($acl->getType() !== Acl::PERMISSION_TYPE_USER) { + $acl->setRetainsAccessViaMembership(false); + continue; + } + + $participant = (string)$acl->getParticipant(); + if ($participant === '') { + $acl->setRetainsAccessViaMembership(false); + continue; + } + + if ($board->getOwnerType() === Acl::PERMISSION_TYPE_USER && $board->getOwner() === $participant) { + $acl->setRetainsAccessViaMembership(true); + continue; + } + + if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE) { + try { + if ($this->circlesService->isUserInCircle($board->getOwner(), $participant)) { + $acl->setRetainsAccessViaMembership(true); + continue; + } + } catch (\Throwable) { + } + } + + $otherAcls = array_filter($acls, static fn (Acl $candidate): bool => $candidate->getId() !== $acl->getId()); + $acl->setRetainsAccessViaMembership($this->permissionService->userCan($otherAcls, Acl::PERMISSION_READ, $participant)); + } + } + private function cloneCards(Board $board, Board $newBoard, bool $withAssignments = false, bool $withLabels = false, bool $withDueDate = false, bool $moveCardsToLeftStack = false, bool $restoreArchivedCards = false): void { $stacks = $this->stackMapper->findAll($board->getId()); $newStacks = $this->stackMapper->findAll($newBoard->getId()); diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 81779a54df..82d7f5ad29 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -135,7 +135,8 @@ public function matchPermissions(Board $board) { Acl::PERMISSION_EDIT => $owner || $this->userCan($acls, Acl::PERMISSION_EDIT), Acl::PERMISSION_MANAGE => $owner || $this->userCan($acls, Acl::PERMISSION_MANAGE), Acl::PERMISSION_SHARE => ($owner || $this->userCan($acls, Acl::PERMISSION_SHARE)) - && (!$this->shareManager->sharingDisabledForUser($this->userId)) + && (!$this->shareManager->sharingDisabledForUser($this->userId)), + Acl::PERMISSION_OWNER => $owner, ]; } diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 2649b2b67d..93dcdeaf81 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -23,6 +23,14 @@ {{ t('deck', 'Team') }} + + + {{ t('deck', 'Transfer ownership to myself') }} + +
    • @@ -64,8 +72,13 @@ - {{ t('deck', 'Delete') }} + {{ acl.type === PERMISSION_TYPE_USER && acl.retainsAccessViaMembership + ? t('deck', 'Remove extra permissions') + : t('deck', 'Delete') }}
    • @@ -129,10 +142,14 @@ export default { 'canEdit', 'canManage', 'canShare', + 'isBoardOwner', ]), isCurrentUser() { return (uid) => uid === getCurrentUser().uid }, + currentUserUid() { + return getCurrentUser().uid + }, canTransferTo() { return (acl) => { const canBeOwnershipTarget = acl.type === PERMISSION_TYPE_USER || acl.type === PERMISSION_TYPE_CIRCLE @@ -144,8 +161,8 @@ export default { if (this.board.ownerType !== PERMISSION_TYPE_CIRCLE) { return this.isCurrentUser(this.board.owner.uid) } - // For circle-owned boards: any circle member with manage rights can transfer - return this.canManage + // For circle-owned boards: only actual board owners (team members) can transfer + return this.isBoardOwner } }, formatedSharees() { diff --git a/src/store/main.js b/src/store/main.js index b8963d24bf..9d20ce79ce 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -127,6 +127,9 @@ export default function storeFactory() { canShare: state => { return state.currentBoard ? state.currentBoard.permissions.PERMISSION_SHARE : false }, + isBoardOwner: state => { + return state.currentBoard ? state.currentBoard.permissions.PERMISSION_OWNER : false + }, isArchived: state => { return state.currentBoard && state.currentBoard.archived }, diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index faea13853d..ae26e13cb3 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -480,6 +480,179 @@ public function testDeleteAcl() { $this->assertEquals($acl, $this->service->deleteAcl(123)); } + public function testFindMarksUserAclAsRetainedViaOwnerCircleMembership(): void { + $board = new Board(); + $board->setId(10); + $board->setOwner('circle-1'); + $board->setOwnerType(Acl::PERMISSION_TYPE_CIRCLE); + + $userAcl = new Acl(); + $userAcl->setId(501); + $userAcl->setBoardId(10); + $userAcl->setType(Acl::PERMISSION_TYPE_USER); + $userAcl->setParticipant('alice'); + $board->setAcl([$userAcl]); + + $this->permissionService->expects($this->once()) + ->method('checkPermission') + ->with($this->boardMapper, 10, Acl::PERMISSION_READ, null, false); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->with(10, true, true, false) + ->willReturn($board); + + $this->boardMapper->expects($this->once()) + ->method('mapOwner') + ->with($board); + + $this->boardMapper->expects($this->once()) + ->method('mapAcl') + ->with($userAcl); + + $this->circlesService->expects($this->once()) + ->method('isUserInCircle') + ->with('circle-1', 'alice') + ->willReturn(true); + + $this->permissionService->expects($this->never()) + ->method('userCan'); + + $this->permissionService->expects($this->once()) + ->method('matchPermissions') + ->with($board) + ->willReturn([ + Acl::PERMISSION_READ => true, + Acl::PERMISSION_EDIT => true, + Acl::PERMISSION_MANAGE => true, + Acl::PERMISSION_SHARE => true, + Acl::PERMISSION_OWNER => true, + ]); + + $result = $this->service->find(10, false); + $this->assertTrue($result->getAcl()[0]->isRetainsAccessViaMembership()); + } + + public function testFindMarksUserAclAsRetainedViaOtherAclMembership(): void { + $board = new Board(); + $board->setId(11); + $board->setOwner('bob'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $userAcl = new Acl(); + $userAcl->setId(601); + $userAcl->setBoardId(11); + $userAcl->setType(Acl::PERMISSION_TYPE_USER); + $userAcl->setParticipant('alice'); + + $groupAcl = new Acl(); + $groupAcl->setId(602); + $groupAcl->setBoardId(11); + $groupAcl->setType(Acl::PERMISSION_TYPE_GROUP); + $groupAcl->setParticipant('devs'); + + $board->setAcl([$userAcl, $groupAcl]); + + $this->permissionService->expects($this->once()) + ->method('checkPermission') + ->with($this->boardMapper, 11, Acl::PERMISSION_READ, null, false); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->with(11, true, true, false) + ->willReturn($board); + + $this->boardMapper->expects($this->once()) + ->method('mapOwner') + ->with($board); + + $this->boardMapper->expects($this->exactly(2)) + ->method('mapAcl') + ->withConsecutive([$userAcl], [$groupAcl]); + + $this->circlesService->expects($this->never()) + ->method('isUserInCircle'); + + $this->permissionService->expects($this->once()) + ->method('userCan') + ->with( + $this->callback(static function (array $otherAcls): bool { + $otherAcls = array_values($otherAcls); + return count($otherAcls) === 1 && $otherAcls[0]->getId() === 602; + }), + Acl::PERMISSION_READ, + 'alice' + ) + ->willReturn(true); + + $this->permissionService->expects($this->once()) + ->method('matchPermissions') + ->with($board) + ->willReturn([ + Acl::PERMISSION_READ => true, + Acl::PERMISSION_EDIT => true, + Acl::PERMISSION_MANAGE => true, + Acl::PERMISSION_SHARE => true, + Acl::PERMISSION_OWNER => false, + ]); + + $result = $this->service->find(11, false); + $this->assertTrue($result->getAcl()[0]->isRetainsAccessViaMembership()); + } + + public function testFindMarksUserAclAsNotRetainedWithoutInheritedAccess(): void { + $board = new Board(); + $board->setId(12); + $board->setOwner('bob'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $userAcl = new Acl(); + $userAcl->setId(701); + $userAcl->setBoardId(12); + $userAcl->setType(Acl::PERMISSION_TYPE_USER); + $userAcl->setParticipant('alice'); + $board->setAcl([$userAcl]); + + $this->permissionService->expects($this->once()) + ->method('checkPermission') + ->with($this->boardMapper, 12, Acl::PERMISSION_READ, null, false); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->with(12, true, true, false) + ->willReturn($board); + + $this->boardMapper->expects($this->once()) + ->method('mapOwner') + ->with($board); + + $this->boardMapper->expects($this->once()) + ->method('mapAcl') + ->with($userAcl); + + $this->circlesService->expects($this->never()) + ->method('isUserInCircle'); + + $this->permissionService->expects($this->once()) + ->method('userCan') + ->with([], Acl::PERMISSION_READ, 'alice') + ->willReturn(false); + + $this->permissionService->expects($this->once()) + ->method('matchPermissions') + ->with($board) + ->willReturn([ + Acl::PERMISSION_READ => true, + Acl::PERMISSION_EDIT => true, + Acl::PERMISSION_MANAGE => true, + Acl::PERMISSION_SHARE => true, + Acl::PERMISSION_OWNER => false, + ]); + + $result = $this->service->find(12, false); + $this->assertFalse($result->getAcl()[0]->isRetainsAccessViaMembership()); + } + public function testTransferBoardOwnershipToCircle(): void { $board = new Board(); $board->setId(10); From 3baaeb92538c0294989a78f80627dd2564ef76ef Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Sat, 2 May 2026 14:43:10 +0200 Subject: [PATCH 19/27] ensure a team stays in sharelist when made owner, just like with users when they transfer ownership. This means that when removing ownership, the team is still in the list of sharees. Signed-off-by: Jos Poortvliet --- lib/Service/BoardService.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 8f8aabe24f..12c1cb91ad 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -646,6 +646,19 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha $this->boardMapper->transferOwnership($previousOwner, $newOwner, $boardId, $newOwnerType); + // When transferring ownership to a circle, re-add the circle as an explicit ACL + // sharee with full rights so it remains visible in the sharing sidebar. + // (It was removed above to prevent a duplicate DB entry.) + if ($newOwnerType === Acl::PERMISSION_TYPE_CIRCLE) { + try { + $this->addAcl($boardId, Acl::PERMISSION_TYPE_CIRCLE, $newOwner, true, true, true); + } catch (DbException $e) { + if ($e->getReason() !== DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + } + } + // Card-content remap is only meaningful when transferring to a user, not a circle if ($changeContent && $newOwnerType === Acl::PERMISSION_TYPE_USER) { $this->assignedUsersMapper->remapAssignedUser($boardId, $previousOwner, $newOwner); From a3dcd624ed5bb4191823ded179e4f5ea26389d7e Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Sat, 2 May 2026 18:20:01 +0200 Subject: [PATCH 20/27] * Ensure we delete boards owned by a circle when the circle gets deleted following the same behavior for users * make sure findAllByOwner is explicit about the type of ownership * Move 'PERMISSION_OWNER' to a sane spot * Fix the annotateAclRetainedAccess missing the owning circle's ACL entry Signed-off-by: Jos Poortvliet --- lib/Command/TransferOwnership.php | 32 ++++- lib/Db/Acl.php | 1 - lib/Db/Board.php | 2 + lib/Db/BoardMapper.php | 5 +- lib/Listeners/ParticipantCleanupListener.php | 7 +- lib/Service/BoardService.php | 26 +++- lib/Service/PermissionService.php | 4 +- src/components/board/SharingTabSidebar.vue | 8 +- tests/integration/import/ImportExportTest.php | 2 +- .../ParticipantCleanupListenerTest.php | 78 +++++++++++ tests/unit/Service/BoardServiceTest.php | 128 +++++++++++++++++- 11 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 tests/unit/Listeners/ParticipantCleanupListenerTest.php diff --git a/lib/Command/TransferOwnership.php b/lib/Command/TransferOwnership.php index 38b20e78ed..9e42acdf43 100644 --- a/lib/Command/TransferOwnership.php +++ b/lib/Command/TransferOwnership.php @@ -70,6 +70,12 @@ protected function configure() { InputOption::VALUE_NONE, 'Treat as a team ID (internally stored as a circle ID) instead of a user UID' ) + ->addOption( + 'from-team', + null, + InputOption::VALUE_NONE, + 'Treat as a team ID (internally stored as a circle ID) instead of a user UID' + ) ; } @@ -79,8 +85,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int $boardId = $input->getArgument('boardId'); $remapAssignment = $input->getOption('remap'); $toTeam = $input->getOption('to-team'); + $fromTeam = $input->getOption('from-team'); + $ownerType = Acl::PERMISSION_TYPE_USER; $newOwnerType = Acl::PERMISSION_TYPE_USER; $teamDisplayName = null; + if ($fromTeam) { + $ownerType = Acl::PERMISSION_TYPE_CIRCLE; + } else { + $ownerUserExists = $this->userManager->userExists($owner); + $ownerCircleExists = false; + if ($this->circlesService->isCirclesEnabled()) { + try { + $ownerCircleExists = $this->circlesService->getCircle($owner) !== null; + } catch (\Throwable $e) { + $ownerCircleExists = false; + } + } + + if ($ownerUserExists && $ownerCircleExists) { + $output->writeln('Ambiguous source owner: ' . $owner . ' matches both a user and a team (circle ID). Use --from-team if you mean the team.'); + return 1; + } + + if ($ownerCircleExists && !$ownerUserExists) { + $ownerType = Acl::PERMISSION_TYPE_CIRCLE; + } + } if ($toTeam) { $newOwnerType = Acl::PERMISSION_TYPE_CIRCLE; if ($this->circlesService->isCirclesEnabled()) { @@ -154,7 +184,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment, $newOwnerType) as $board) { + foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment, $newOwnerType, $ownerType) as $board) { $output->writeln(' - ' . $board->getTitle() . ' transferred'); } $output->writeln("All boards from $owner transferred to $newOwnerLabel"); diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 8f7fe660a9..7fb1d7a986 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -30,7 +30,6 @@ class Acl extends RelationalEntity { public const PERMISSION_EDIT = 1; public const PERMISSION_SHARE = 2; public const PERMISSION_MANAGE = 3; - public const PERMISSION_OWNER = 4; public const PERMISSION_TYPE_USER = 0; public const PERMISSION_TYPE_GROUP = 1; diff --git a/lib/Db/Board.php b/lib/Db/Board.php index 80865dcb67..ee84880fae 100644 --- a/lib/Db/Board.php +++ b/lib/Db/Board.php @@ -32,6 +32,8 @@ * @method int | null getExternalId() */ class Board extends RelationalEntity { + public const PERMISSION_OWNER = 4; + protected $title; protected $owner; protected $ownerType = 0; diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index 41ade67f2a..d912cb2b51 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -295,11 +295,12 @@ public function findAllByUser(string $userId, ?int $limit = null, ?int $offset = return $entries; } - public function findAllByOwner(string $userId, ?int $limit = null, ?int $offset = null) { + public function findAllByOwner(string $ownerId, int $ownerType = Acl::PERMISSION_TYPE_USER, ?int $limit = null, ?int $offset = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from('deck_boards') - ->where($qb->expr()->eq('owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))) + ->where($qb->expr()->eq('owner', $qb->createNamedParameter($ownerId, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('owner_type', $qb->createNamedParameter($ownerType, IQueryBuilder::PARAM_INT))) ->orderBy('id'); if ($limit !== null) { $qb->setMaxResults($limit); diff --git a/lib/Listeners/ParticipantCleanupListener.php b/lib/Listeners/ParticipantCleanupListener.php index 3ba4e27c78..1cc837040e 100644 --- a/lib/Listeners/ParticipantCleanupListener.php +++ b/lib/Listeners/ParticipantCleanupListener.php @@ -30,7 +30,7 @@ public function __construct(AclMapper $aclMapper, AssignmentMapper $assignmentMa public function handle(Event $event): void { if ($event instanceof UserDeletedEvent) { - $boards = $this->boardMapper->findAllByOwner($event->getUser()->getUID()); + $boards = $this->boardMapper->findAllByOwner($event->getUser()->getUID(), Acl::PERMISSION_TYPE_USER); foreach ($boards as $board) { $this->boardMapper->delete($board); } @@ -44,6 +44,11 @@ public function handle(Event $event): void { if ($event instanceof CircleDestroyedEvent) { $circleId = $event->getCircle()->getSingleId(); + $boards = $this->boardMapper->findAllByOwner($circleId, Acl::PERMISSION_TYPE_CIRCLE); + foreach ($boards as $board) { + $this->boardMapper->delete($board); + } + $this->cleanupByParticipant(Acl::PERMISSION_TYPE_CIRCLE, $circleId); } } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 12c1cb91ad..8f76c9c179 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -223,7 +223,7 @@ public function create(string $title, string $userId, string $color): Board { 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, - 'PERMISSION_OWNER' => $permissions[Acl::PERMISSION_OWNER] ?? false, + 'PERMISSION_OWNER' => $permissions[Board::PERMISSION_OWNER] ?? false, ]); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $board, ActivityManager::SUBJECT_BOARD_CREATE, [], $userId); $this->changeHelper->boardChanged($board->getId()); @@ -575,7 +575,7 @@ public function clone( 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, - 'PERMISSION_OWNER' => $permissions[Acl::PERMISSION_OWNER] ?? false, + 'PERMISSION_OWNER' => $permissions[Board::PERMISSION_OWNER] ?? false, ]); $this->boardMapper->insert($newBoard); @@ -673,10 +673,10 @@ public function transferBoardOwnership(int $boardId, string $newOwner, bool $cha } } - public function transferOwnership(string $owner, string $newOwner, bool $changeContent = false, int $newOwnerType = Acl::PERMISSION_TYPE_USER): \Generator { + public function transferOwnership(string $owner, string $newOwner, bool $changeContent = false, int $newOwnerType = Acl::PERMISSION_TYPE_USER, int $ownerType = Acl::PERMISSION_TYPE_USER): \Generator { // Validate once up front so invalid targets fail even if no boards match. $this->validateTransferTarget($newOwner, $newOwnerType); - $boards = $this->boardMapper->findAllByOwner($owner); + $boards = $this->boardMapper->findAllByOwner($owner, $ownerType); foreach ($boards as $board) { yield $this->transferBoardOwnership($board->getId(), $newOwner, $changeContent, $newOwnerType); } @@ -735,7 +735,7 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, - 'PERMISSION_OWNER' => $permissions[Acl::PERMISSION_OWNER] ?? false, + 'PERMISSION_OWNER' => $permissions[Board::PERMISSION_OWNER] ?? false, ]); if ($fullDetails) { @@ -760,6 +760,22 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { private function annotateAclRetainedAccess(Board $board): void { $acls = $board->getAcl() ?? []; foreach ($acls as $acl) { + if ($acl->getType() === Acl::PERMISSION_TYPE_GROUP) { + $acl->setRetainsAccessViaMembership(true); + continue; + } + + if ($acl->getType() === Acl::PERMISSION_TYPE_CIRCLE) { + if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE + && (string)$acl->getParticipant() === $board->getOwner()) { + $acl->setRetainsAccessViaMembership(true); + continue; + } + + $acl->setRetainsAccessViaMembership(true); + continue; + } + if ($acl->getType() !== Acl::PERMISSION_TYPE_USER) { $acl->setRetainsAccessViaMembership(false); continue; diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 82d7f5ad29..fd8d36c43d 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -116,7 +116,7 @@ public function getPermissions(int $boardId, ?string $userId = null, bool $allow * Get current user permissions for a board * * @param Board $board - * @return array|bool + * @return array * @internal param $boardId */ public function matchPermissions(Board $board) { @@ -136,7 +136,7 @@ public function matchPermissions(Board $board) { Acl::PERMISSION_MANAGE => $owner || $this->userCan($acls, Acl::PERMISSION_MANAGE), Acl::PERMISSION_SHARE => ($owner || $this->userCan($acls, Acl::PERMISSION_SHARE)) && (!$this->shareManager->sharingDisabledForUser($this->userId)), - Acl::PERMISSION_OWNER => $owner, + Board::PERMISSION_OWNER => $owner, ]; } diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 93dcdeaf81..defdf8d34f 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -25,7 +25,7 @@ {{ t('deck', 'Transfer ownership to myself') }} @@ -64,7 +64,7 @@ {{ t('deck', 'Can manage') }} {{ t('deck', 'Transfer ownership') }} @@ -72,11 +72,11 @@ - {{ acl.type === PERMISSION_TYPE_USER && acl.retainsAccessViaMembership + {{ acl.retainsAccessViaMembership ? t('deck', 'Remove extra permissions') : t('deck', 'Delete') }} diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index 261a01756c..247830e06a 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -279,7 +279,7 @@ public function assertDatabase(string $owner = 'admin') { $stackMapper = self::getFreshService(StackMapper::class); $cardMapper = self::getFreshService(CardMapper::class); - $boards = $boardMapper->findAllByOwner($owner); + $boards = $boardMapper->findAllByOwner($owner, Acl::PERMISSION_TYPE_USER); $boardNames = array_map(fn ($board) => $board->getTitle(), $boards); self::assertEquals(2, count($boards)); diff --git a/tests/unit/Listeners/ParticipantCleanupListenerTest.php b/tests/unit/Listeners/ParticipantCleanupListenerTest.php new file mode 100644 index 0000000000..1b6fd70d55 --- /dev/null +++ b/tests/unit/Listeners/ParticipantCleanupListenerTest.php @@ -0,0 +1,78 @@ +aclMapper = $this->createMock(AclMapper::class); + $this->assignmentMapper = $this->createMock(AssignmentMapper::class); + $this->boardMapper = $this->createMock(BoardMapper::class); + $this->listener = new ParticipantCleanupListener($this->aclMapper, $this->assignmentMapper, $this->boardMapper); + } + + public function testCircleDestroyedDeletesCircleOwnedBoardsAndCleansParticipantData(): void { + $circleId = 'circle-123'; + + $circle = $this->createMock(Circle::class); + $circle->expects($this->once()) + ->method('getSingleId') + ->willReturn($circleId); + + $event = $this->createMock(CircleDestroyedEvent::class); + $event->expects($this->once()) + ->method('getCircle') + ->willReturn($circle); + + $boardOne = new Board(); + $boardTwo = new Board(); + $this->boardMapper->expects($this->once()) + ->method('findAllByOwner') + ->with($circleId, Acl::PERMISSION_TYPE_CIRCLE) + ->willReturn([$boardOne, $boardTwo]); + $this->boardMapper->expects($this->exactly(2)) + ->method('delete'); + + $acl = new Acl(); + $this->aclMapper->expects($this->once()) + ->method('findByParticipant') + ->with(Acl::PERMISSION_TYPE_CIRCLE, $circleId) + ->willReturn([$acl]); + $this->aclMapper->expects($this->once()) + ->method('delete') + ->with($acl); + + $assignment = new Assignment(); + $this->assignmentMapper->expects($this->once()) + ->method('findByParticipant') + ->with($circleId, Acl::PERMISSION_TYPE_CIRCLE) + ->willReturn([$assignment]); + $this->assignmentMapper->expects($this->once()) + ->method('delete') + ->with($assignment); + + $this->listener->handle($event); + } +} diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index ae26e13cb3..83ed22768e 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -526,7 +526,7 @@ public function testFindMarksUserAclAsRetainedViaOwnerCircleMembership(): void { Acl::PERMISSION_EDIT => true, Acl::PERMISSION_MANAGE => true, Acl::PERMISSION_SHARE => true, - Acl::PERMISSION_OWNER => true, + Board::PERMISSION_OWNER => true, ]); $result = $this->service->find(10, false); @@ -593,11 +593,12 @@ public function testFindMarksUserAclAsRetainedViaOtherAclMembership(): void { Acl::PERMISSION_EDIT => true, Acl::PERMISSION_MANAGE => true, Acl::PERMISSION_SHARE => true, - Acl::PERMISSION_OWNER => false, + Board::PERMISSION_OWNER => false, ]); $result = $this->service->find(11, false); $this->assertTrue($result->getAcl()[0]->isRetainsAccessViaMembership()); + $this->assertTrue($result->getAcl()[1]->isRetainsAccessViaMembership()); } public function testFindMarksUserAclAsNotRetainedWithoutInheritedAccess(): void { @@ -646,7 +647,7 @@ public function testFindMarksUserAclAsNotRetainedWithoutInheritedAccess(): void Acl::PERMISSION_EDIT => true, Acl::PERMISSION_MANAGE => true, Acl::PERMISSION_SHARE => true, - Acl::PERMISSION_OWNER => false, + Board::PERMISSION_OWNER => false, ]); $result = $this->service->find(12, false); @@ -684,10 +685,10 @@ public function testTransferBoardOwnershipToCircle(): void { ->with(10, Acl::PERMISSION_TYPE_CIRCLE, 'circle-id-xyz'); // Previous user owner gets an ACL entry when changeContent = false - $this->aclMapper->expects($this->once()) + $this->aclMapper->expects($this->exactly(2)) ->method('findAll') ->willReturn([]); - $this->aclMapper->expects($this->once()) + $this->aclMapper->expects($this->exactly(2)) ->method('insert') ->willReturnCallback(fn ($acl) => $acl); @@ -703,6 +704,108 @@ public function testTransferBoardOwnershipToCircle(): void { $this->assertSame($updatedBoard, $result); } + public function testFindMarksCircleAclAsRetainedViaMembership(): void { + $board = new Board(); + $board->setId(13); + $board->setOwner('bob'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $circleAcl = new Acl(); + $circleAcl->setId(801); + $circleAcl->setBoardId(13); + $circleAcl->setType(Acl::PERMISSION_TYPE_CIRCLE); + $circleAcl->setParticipant('circle-2'); + $board->setAcl([$circleAcl]); + + $this->permissionService->expects($this->once()) + ->method('checkPermission') + ->with($this->boardMapper, 13, Acl::PERMISSION_READ, null, false); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->with(13, true, true, false) + ->willReturn($board); + + $this->boardMapper->expects($this->once()) + ->method('mapOwner') + ->with($board); + + $this->boardMapper->expects($this->once()) + ->method('mapAcl') + ->with($circleAcl); + + $this->circlesService->expects($this->never()) + ->method('isUserInCircle'); + + $this->permissionService->expects($this->never()) + ->method('userCan'); + + $this->permissionService->expects($this->once()) + ->method('matchPermissions') + ->with($board) + ->willReturn([ + Acl::PERMISSION_READ => true, + Acl::PERMISSION_EDIT => true, + Acl::PERMISSION_MANAGE => true, + Acl::PERMISSION_SHARE => true, + Board::PERMISSION_OWNER => false, + ]); + + $result = $this->service->find(13, false); + $this->assertTrue($result->getAcl()[0]->isRetainsAccessViaMembership()); + } + + public function testFindMarksRemoteAclAsNotRetainedViaMembership(): void { + $board = new Board(); + $board->setId(14); + $board->setOwner('bob'); + $board->setOwnerType(Acl::PERMISSION_TYPE_USER); + + $remoteAcl = new Acl(); + $remoteAcl->setId(901); + $remoteAcl->setBoardId(14); + $remoteAcl->setType(Acl::PERMISSION_TYPE_REMOTE); + $remoteAcl->setParticipant('https://remote.example'); + $board->setAcl([$remoteAcl]); + + $this->permissionService->expects($this->once()) + ->method('checkPermission') + ->with($this->boardMapper, 14, Acl::PERMISSION_READ, null, false); + + $this->boardMapper->expects($this->once()) + ->method('find') + ->with(14, true, true, false) + ->willReturn($board); + + $this->boardMapper->expects($this->once()) + ->method('mapOwner') + ->with($board); + + $this->boardMapper->expects($this->once()) + ->method('mapAcl') + ->with($remoteAcl); + + $this->circlesService->expects($this->never()) + ->method('isUserInCircle'); + + $this->permissionService->expects($this->never()) + ->method('userCan'); + + $this->permissionService->expects($this->once()) + ->method('matchPermissions') + ->with($board) + ->willReturn([ + Acl::PERMISSION_READ => true, + Acl::PERMISSION_EDIT => true, + Acl::PERMISSION_MANAGE => true, + Acl::PERMISSION_SHARE => true, + Board::PERMISSION_OWNER => false, + ]); + + $result = $this->service->find(14, false); + $this->assertFalse($result->getAcl()[0]->isRetainsAccessViaMembership()); + } + public function testTransferBoardOwnershipToNonExistentUserThrows(): void { $board = new Board(); $board->setId(10); @@ -725,6 +828,21 @@ public function testTransferBoardOwnershipToNonExistentUserThrows(): void { $this->service->transferBoardOwnership(10, 'ghost', false, Acl::PERMISSION_TYPE_USER); } + public function testTransferOwnershipUsesSourceOwnerTypeWhenFetchingBoards(): void { + $this->userManager->expects($this->once()) + ->method('userExists') + ->with('bob') + ->willReturn(true); + + $this->boardMapper->expects($this->once()) + ->method('findAllByOwner') + ->with('circle-1', Acl::PERMISSION_TYPE_CIRCLE) + ->willReturn([]); + + $result = iterator_to_array($this->service->transferOwnership('circle-1', 'bob', false, Acl::PERMISSION_TYPE_USER, Acl::PERMISSION_TYPE_CIRCLE)); + $this->assertSame([], $result); + } + public function testTransferBoardOwnershipToNonExistentCircleThrows(): void { $board = new Board(); $board->setId(10); From 55bbcdc869a3c357f68c7da66768fcc580bbff38 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Mon, 4 May 2026 02:28:23 +0200 Subject: [PATCH 21/27] fix some boards not showing up for users when added to a circle after initial share and improve performance. Signed-off-by: Jos Poortvliet --- lib/Service/BoardService.php | 53 +++++---- lib/Service/CirclesService.php | 122 +++++++++++++++++---- lib/Service/PermissionService.php | 9 ++ tests/unit/Service/BoardServiceTest.php | 65 ++++++++++- tests/unit/Service/CirclesServiceTest.php | 126 ++++++++++++++++++++++ 5 files changed, 332 insertions(+), 43 deletions(-) create mode 100644 tests/unit/Service/CirclesServiceTest.php diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 8f76c9c179..f625d8cce1 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -8,6 +8,7 @@ namespace OCA\Deck\Service; use OC\User\LazyUser; +use OCA\Circles\Model\Member; use OCA\Deck\Activity\ActivityManager; use OCA\Deck\Activity\ChangeSet; use OCA\Deck\AppInfo\Application; @@ -412,6 +413,7 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ $board = $this->boardMapper->find($boardId); $this->clearBoardFromCache($board); + $this->invalidateAclCaches($boardId, $type, (string)$participant); // TODO: use the dispatched event for this try { @@ -474,6 +476,7 @@ public function updateAcl(int $id, bool $edit, bool $share, bool $manage): Acl { } $this->eventDispatcher->dispatchTyped(new AclUpdatedEvent($acl)); + $this->invalidateAclCaches($acl->getBoardId(), $acl->getType(), (string)$acl->getParticipant()); return $acl; } @@ -510,6 +513,7 @@ public function deleteAcl(int $id): ?Acl { $deletedAcl = $this->aclMapper->delete($acl); $this->eventDispatcher->dispatchTyped(new AclDeletedEvent($acl)); + $this->invalidateAclCaches($acl->getBoardId(), $acl->getType(), (string)$acl->getParticipant()); return $deletedAcl; } @@ -759,23 +763,26 @@ private function enrichBoards(array $boards, bool $fullDetails = true): array { private function annotateAclRetainedAccess(Board $board): void { $acls = $board->getAcl() ?? []; - foreach ($acls as $acl) { - if ($acl->getType() === Acl::PERMISSION_TYPE_GROUP) { - $acl->setRetainsAccessViaMembership(true); - continue; - } - if ($acl->getType() === Acl::PERMISSION_TYPE_CIRCLE) { - if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE - && (string)$acl->getParticipant() === $board->getOwner()) { - $acl->setRetainsAccessViaMembership(true); - continue; + // For circle-owned boards fetch members once instead of N isUserInCircle() calls + $circleMemberIds = null; + if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE + && $this->circlesService->isCirclesEnabled() + ) { + $circle = $this->circlesService->getCircle($board->getOwner()); + if ($circle !== null) { + $circleMemberIds = []; + foreach ($circle->getInheritedMembers() as $member) { + if ($member->getUserType() === Member::TYPE_USER + && $member->getLevel() >= Member::LEVEL_MEMBER + ) { + $circleMemberIds[$member->getUserId()] = true; + } } - - $acl->setRetainsAccessViaMembership(true); - continue; } + } + foreach ($acls as $acl) { if ($acl->getType() !== Acl::PERMISSION_TYPE_USER) { $acl->setRetainsAccessViaMembership(false); continue; @@ -792,14 +799,10 @@ private function annotateAclRetainedAccess(Board $board): void { continue; } - if ($board->getOwnerType() === Acl::PERMISSION_TYPE_CIRCLE) { - try { - if ($this->circlesService->isUserInCircle($board->getOwner(), $participant)) { - $acl->setRetainsAccessViaMembership(true); - continue; - } - } catch (\Throwable) { - } + // Circle-owned board: check prebuilt membership set (1 Circles call total) + if ($circleMemberIds !== null) { + $acl->setRetainsAccessViaMembership(isset($circleMemberIds[$participant])); + continue; } $otherAcls = array_filter($acls, static fn (Acl $candidate): bool => $candidate->getId() !== $acl->getId()); @@ -925,6 +928,14 @@ private function clearBoardFromCache(Board $board): void { unset($this->boardsCachePartial[$boardId]); } + private function invalidateAclCaches(int $boardId, int $aclType, string $participant): void { + $this->permissionService->clearUsersCache($boardId); + + if ($aclType === Acl::PERMISSION_TYPE_CIRCLE && $participant !== '') { + $this->circlesService->clearUserCircleCache($participant); + } + } + private function enrichWithCards(Board $board): void { $stacks = $this->stackMapper->findAll($board->getId()); if (\count($stacks) === 0) { diff --git a/lib/Service/CirclesService.php b/lib/Service/CirclesService.php index 05eac6f59c..f557e3566d 100644 --- a/lib/Service/CirclesService.php +++ b/lib/Service/CirclesService.php @@ -17,6 +17,7 @@ use OCA\Circles\Model\Probes\DataProbe; use OCP\App\IAppManager; use OCP\Server; +use Psr\Log\LoggerInterface; use Throwable; /** @@ -30,7 +31,7 @@ class CirclesService { /** @var array */ private array $userCirclesCache = []; - public function __construct(IAppManager $appManager) { + public function __construct(IAppManager $appManager, private ?LoggerInterface $logger = null) { $this->circlesEnabled = $appManager->isEnabledForUser('circles'); } @@ -44,14 +45,21 @@ public function getCircle(string $circleId): ?Circle { } try { - // Enforce current user condition since we always want the full list of members - $circlesManager = Server::get(CirclesManager::class); + $circlesManager = $this->getCirclesManager(); $circlesManager->startSuperSession(); - $dataProbe = new DataProbe(); - $dataProbe->add(DataProbe::OWNER); - return $circlesManager->probeCircle($circleId, null, $dataProbe); + try { + $dataProbe = new DataProbe(); + $dataProbe->add(DataProbe::OWNER); + return $circlesManager->probeCircle($circleId, null, $dataProbe); + } finally { + $this->stopSessionSafely($circlesManager, 'getCircle'); + } } catch (Throwable $e) { + $this->logger?->debug('CirclesService::getCircle failed', [ + 'circleId' => $circleId, + 'exception' => $e, + ]); } return null; } @@ -66,14 +74,18 @@ public function isUserInCircle(string $circleId, string $userId): bool { } try { - $circlesManager = Server::get(CirclesManager::class); + $circlesManager = $this->getCirclesManager(); $federatedUser = $circlesManager->getFederatedUser($userId, Member::TYPE_USER); $circlesManager->startSession($federatedUser); - $dataProbe = new DataProbe(); - $dataProbe->add(DataProbe::INITIATOR); - $circle = $circlesManager->probeCircle($circleId, null, $dataProbe); - $member = $circle->getInitiator(); - $isUserInCircle = $member->getLevel() >= Member::LEVEL_MEMBER; + try { + $dataProbe = new DataProbe(); + $dataProbe->add(DataProbe::INITIATOR); + $circle = $circlesManager->probeCircle($circleId, null, $dataProbe); + $member = $circle->getInitiator(); + $isUserInCircle = $member->getLevel() >= Member::LEVEL_MEMBER; + } finally { + $this->stopSessionSafely($circlesManager, 'isUserInCircle'); + } if (!isset($this->userCircleCache[$circleId])) { $this->userCircleCache[$circleId] = []; @@ -82,6 +94,11 @@ public function isUserInCircle(string $circleId, string $userId): bool { return $isUserInCircle; } catch (Throwable $e) { + $this->logger?->debug('CirclesService::isUserInCircle failed', [ + 'circleId' => $circleId, + 'userId' => $userId, + 'exception' => $e, + ]); } return false; } @@ -100,18 +117,87 @@ public function getUserCircles(string $userId): array { } try { - $circlesManager = Server::get(CirclesManager::class); + $circlesManager = $this->getCirclesManager(); $federatedUser = $circlesManager->getFederatedUser($userId, Member::TYPE_USER); $circlesManager->startSession($federatedUser); - $probe = new CircleProbe(); - $probe->mustBeMember(); - $circles = array_map(function (Circle $circle) { - return $circle->getSingleId(); - }, $circlesManager->probeCircles($probe)); + try { + $probe = new CircleProbe(); + $probe->mustBeMember(); + $circles = array_map(function (Circle $circle) { + return $circle->getSingleId(); + }, $circlesManager->probeCircles($probe)); + } finally { + $this->stopSessionSafely($circlesManager, 'getUserCircles'); + } $this->userCirclesCache[$userId] = $circles; return $circles; } catch (Throwable $e) { + $this->logger?->debug('CirclesService::getUserCircles failed', [ + 'userId' => $userId, + 'exception' => $e, + ]); } return []; } + + public function clearUserCircleCache(?string $circleId = null, ?string $userId = null): void { + if ($circleId === null && $userId === null) { + $this->userCircleCache = []; + return; + } + + if ($circleId !== null && $userId === null) { + unset($this->userCircleCache[$circleId]); + return; + } + + if ($circleId !== null && $userId !== null) { + unset($this->userCircleCache[$circleId][$userId]); + if (empty($this->userCircleCache[$circleId])) { + unset($this->userCircleCache[$circleId]); + } + return; + } + + foreach ($this->userCircleCache as $cachedCircleId => $users) { + unset($users[$userId]); + if (empty($users)) { + unset($this->userCircleCache[$cachedCircleId]); + continue; + } + $this->userCircleCache[$cachedCircleId] = $users; + } + } + + public function clearUserCirclesCache(?string $userId = null): void { + if ($userId === null) { + $this->userCirclesCache = []; + return; + } + + unset($this->userCirclesCache[$userId]); + } + + public function clearAllCaches(): void { + $this->clearUserCircleCache(); + $this->clearUserCirclesCache(); + } + + protected function getCirclesManager(): CirclesManager { + return Server::get(CirclesManager::class); + } + + private function stopSessionSafely(CirclesManager $circlesManager, string $method): void { + if (!method_exists($circlesManager, 'stopSession')) { + return; + } + + try { + $circlesManager->stopSession(); + } catch (Throwable $e) { + $this->logger?->debug('CirclesService::' . $method . ' stopSession failed', [ + 'exception' => $e, + ]); + } + } } diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index fd8d36c43d..f6ca56cc1c 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -405,4 +405,13 @@ public function setUserId(string $userId): void { $this->userId = $userId; $this->permissionCache->clear(); } + + public function clearUsersCache(?int $boardId = null): void { + if ($boardId === null) { + $this->users = []; + return; + } + + unset($this->users[(string)$boardId]); + } } diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 83ed22768e..9299ef2052 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -313,11 +313,43 @@ public function testAddAcl() { ->willReturn([ 'admin' => 'admin', ]); + $this->permissionService->expects($this->once()) + ->method('clearUsersCache') + ->with(123); + $this->circlesService->expects($this->never()) + ->method('clearUserCircleCache'); $this->assertEquals($acl, $this->service->addAcl( 123, Acl::PERMISSION_TYPE_USER, 'admin', true, true, true )); } + public function testAddCircleAclClearsCircleMembershipCache(): void { + $acl = new Acl(); + $acl->setBoardId(123); + $acl->setType(Acl::PERMISSION_TYPE_CIRCLE); + $acl->setParticipant('circle-1'); + $acl->setPermissionEdit(true); + $acl->setPermissionShare(true); + $acl->setPermissionManage(true); + + $this->notificationHelper->expects($this->once()) + ->method('sendBoardShared'); + $this->aclMapper->expects($this->once()) + ->method('insert') + ->with($acl) + ->willReturn($acl); + $this->permissionService->expects($this->once()) + ->method('clearUsersCache') + ->with(123); + $this->circlesService->expects($this->once()) + ->method('clearUserCircleCache') + ->with('circle-1'); + + $this->assertEquals($acl, $this->service->addAcl( + 123, Acl::PERMISSION_TYPE_CIRCLE, 'circle-1', true, true, true + )); + } + public static function dataAddAclExtendPermission() { return [ [[false, false, false], [false, false, false], [false, false, false]], @@ -439,6 +471,9 @@ public function testUpdateAcl() { ->method('update') ->with($acl) ->willReturn($acl); + $this->permissionService->expects($this->once()) + ->method('clearUsersCache') + ->with(123); $result = $this->service->updateAcl( 123, false, false, false @@ -474,6 +509,9 @@ public function testDeleteAcl() { ->method('delete') ->with($acl) ->willReturn($acl); + $this->permissionService->expects($this->once()) + ->method('clearUsersCache') + ->with(123); $this->eventDispatcher->expects(self::once()) ->method('dispatchTyped') ->with(new AclDeletedEvent($acl)); @@ -510,10 +548,29 @@ public function testFindMarksUserAclAsRetainedViaOwnerCircleMembership(): void { ->method('mapAcl') ->with($userAcl); + $member = $this->createMock(\OCA\Circles\Model\Member::class); + $member->expects($this->once()) + ->method('getUserType') + ->willReturn(\OCA\Circles\Model\Member::TYPE_USER); + $member->expects($this->once()) + ->method('getLevel') + ->willReturn(\OCA\Circles\Model\Member::LEVEL_MEMBER); + $member->expects($this->once()) + ->method('getUserId') + ->willReturn('alice'); + + $circle = $this->createMock(\OCA\Circles\Model\Circle::class); + $circle->expects($this->once()) + ->method('getInheritedMembers') + ->willReturn([$member]); + $this->circlesService->expects($this->once()) - ->method('isUserInCircle') - ->with('circle-1', 'alice') + ->method('isCirclesEnabled') ->willReturn(true); + $this->circlesService->expects($this->once()) + ->method('getCircle') + ->with('circle-1') + ->willReturn($circle); $this->permissionService->expects($this->never()) ->method('userCan'); @@ -598,7 +655,7 @@ public function testFindMarksUserAclAsRetainedViaOtherAclMembership(): void { $result = $this->service->find(11, false); $this->assertTrue($result->getAcl()[0]->isRetainsAccessViaMembership()); - $this->assertTrue($result->getAcl()[1]->isRetainsAccessViaMembership()); + $this->assertFalse($result->getAcl()[1]->isRetainsAccessViaMembership()); } public function testFindMarksUserAclAsNotRetainedWithoutInheritedAccess(): void { @@ -752,7 +809,7 @@ public function testFindMarksCircleAclAsRetainedViaMembership(): void { ]); $result = $this->service->find(13, false); - $this->assertTrue($result->getAcl()[0]->isRetainsAccessViaMembership()); + $this->assertFalse($result->getAcl()[0]->isRetainsAccessViaMembership()); } public function testFindMarksRemoteAclAsNotRetainedViaMembership(): void { diff --git a/tests/unit/Service/CirclesServiceTest.php b/tests/unit/Service/CirclesServiceTest.php new file mode 100644 index 0000000000..3c6b96e603 --- /dev/null +++ b/tests/unit/Service/CirclesServiceTest.php @@ -0,0 +1,126 @@ +appManager = $this->createMock(IAppManager::class); + $this->appManager->method('isEnabledForUser')->with('circles')->willReturn(true); + } + + public function testGetUserCirclesStopsSessionOnSuccess(): void { + $circle = $this->createMock(Circle::class); + $circle->expects($this->once()) + ->method('getSingleId') + ->willReturn('circle-1'); + + $federatedUser = $this->createMock(FederatedUser::class); + $manager = $this->getMockBuilder(CirclesManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['getFederatedUser', 'startSession', 'getCircles']) + ->addMethods(['stopSession']) + ->getMock(); + + $manager->expects($this->once()) + ->method('getFederatedUser') + ->with('alice', Member::TYPE_USER) + ->willReturn($federatedUser); + $manager->expects($this->once()) + ->method('startSession') + ->with($federatedUser); + $manager->expects($this->once()) + ->method('getCircles') + ->willReturn([$circle]); + $manager->expects($this->once()) + ->method('stopSession'); + + $service = $this->getMockBuilder(CirclesService::class) + ->setConstructorArgs([$this->appManager]) + ->onlyMethods(['getCirclesManager']) + ->getMock(); + $service->expects($this->once()) + ->method('getCirclesManager') + ->willReturn($manager); + + $result = $service->getUserCircles('alice'); + $this->assertSame(['circle-1'], $result); + } + + public function testGetUserCirclesStopsSessionOnFailure(): void { + $federatedUser = $this->createMock(FederatedUser::class); + $manager = $this->getMockBuilder(CirclesManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['getFederatedUser', 'startSession', 'getCircles']) + ->addMethods(['stopSession']) + ->getMock(); + + $manager->expects($this->once()) + ->method('getFederatedUser') + ->with('alice', Member::TYPE_USER) + ->willReturn($federatedUser); + $manager->expects($this->once()) + ->method('startSession') + ->with($federatedUser); + $manager->expects($this->once()) + ->method('getCircles') + ->willThrowException(new \RuntimeException('Boom')); + $manager->expects($this->once()) + ->method('stopSession'); + + $service = $this->getMockBuilder(CirclesService::class) + ->setConstructorArgs([$this->appManager]) + ->onlyMethods(['getCirclesManager']) + ->getMock(); + $service->expects($this->once()) + ->method('getCirclesManager') + ->willReturn($manager); + + $this->assertSame([], $service->getUserCircles('alice')); + } + + public function testGetCircleStopsSuperSession(): void { + $circle = $this->createMock(Circle::class); + $manager = $this->getMockBuilder(CirclesManager::class) + ->disableOriginalConstructor() + ->onlyMethods(['startSuperSession', 'getCircle']) + ->addMethods(['stopSession']) + ->getMock(); + + $manager->expects($this->once()) + ->method('startSuperSession'); + $manager->expects($this->once()) + ->method('getCircle') + ->with('circle-1') + ->willReturn($circle); + $manager->expects($this->once()) + ->method('stopSession'); + + $service = $this->getMockBuilder(CirclesService::class) + ->setConstructorArgs([$this->appManager]) + ->onlyMethods(['getCirclesManager']) + ->getMock(); + $service->expects($this->once()) + ->method('getCirclesManager') + ->willReturn($manager); + + $this->assertSame($circle, $service->getCircle('circle-1')); + } +} From 81af66a2739ee24245c4f97bd081e709b3982527 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Mon, 4 May 2026 14:53:36 +0200 Subject: [PATCH 22/27] Fix php-cs-fixer violations in circle ownership feature - NotificationHelper: alphabetise imports (CirclesService before ConfigService) - Notifier: remove extra indentation level from isTeamAssignment block - CirclesService: reformat constructor to multi-line with trailing comma - BoardService: remove trailing whitespace on PERMISSION_OWNER line Co-Authored-By: Claude Sonnet 4.6 --- lib/Notification/NotificationHelper.php | 2 +- lib/Notification/Notifier.php | 114 ++++++++++++------------ lib/Service/BoardService.php | 2 +- lib/Service/CirclesService.php | 5 +- 4 files changed, 63 insertions(+), 60 deletions(-) diff --git a/lib/Notification/NotificationHelper.php b/lib/Notification/NotificationHelper.php index b20e053b32..07c23d8c0c 100644 --- a/lib/Notification/NotificationHelper.php +++ b/lib/Notification/NotificationHelper.php @@ -20,8 +20,8 @@ use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\User; -use OCA\Deck\Service\ConfigService; use OCA\Deck\Service\CirclesService; +use OCA\Deck\Service\ConfigService; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 5dc6a05898..3e43faaa1d 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -79,65 +79,65 @@ public function prepare(INotification $notification, string $languageCode): INot } else { $dn = $params[2]; } - $isTeamAssignment = isset($params[3]) && $params[3] === 'team'; - if ($isTeamAssignment) { - $teamName = $params[4] ?? ''; - $notification->setParsedSubject( - $l->t('The card "%1$s" on "%2$s" has been assigned to team "%3$s" by %4$s. You are receiving this because you are a member of that team.', [$params[0], $params[1], $teamName, $dn]) - ); - $notification->setRichSubject( - $l->t('{user} has assigned the card {deck-card} on {deck-board} to a team you are a member of.'), - [ - 'deck-card' => [ - 'type' => 'deck-card', - 'id' => (string)$cardId, - 'name' => $params[0], - 'boardname' => (string)$params[1], - 'stackname' => $stack->getTitle(), - 'link' => $this->getCardUrl($boardId, $cardId), - ], - 'deck-board' => [ - 'type' => 'deck-board', - 'id' => (string)$boardId, - 'name' => (string)$params[1], - 'link' => $this->getBoardUrl($boardId), - ], - 'user' => [ - 'type' => 'user', - 'id' => (string)$params[2], - 'name' => $dn, - ] + $isTeamAssignment = isset($params[3]) && $params[3] === 'team'; + if ($isTeamAssignment) { + $teamName = $params[4] ?? ''; + $notification->setParsedSubject( + $l->t('The card "%1$s" on "%2$s" has been assigned to team "%3$s" by %4$s. You are receiving this because you are a member of that team.', [$params[0], $params[1], $teamName, $dn]) + ); + $notification->setRichSubject( + $l->t('{user} has assigned the card {deck-card} on {deck-board} to a team you are a member of.'), + [ + 'deck-card' => [ + 'type' => 'deck-card', + 'id' => (string)$cardId, + 'name' => $params[0], + 'boardname' => (string)$params[1], + 'stackname' => $stack->getTitle(), + 'link' => $this->getCardUrl($boardId, $cardId), + ], + 'deck-board' => [ + 'type' => 'deck-board', + 'id' => (string)$boardId, + 'name' => (string)$params[1], + 'link' => $this->getBoardUrl($boardId), + ], + 'user' => [ + 'type' => 'user', + 'id' => (string)$params[2], + 'name' => $dn, ] - ); - } else { - $notification->setParsedSubject( - $l->t('The card "%s" on "%s" has been assigned to you by %s.', [$params[0], $params[1], $dn]) - ); - $notification->setRichSubject( - $l->t('{user} has assigned the card {deck-card} on {deck-board} to you.'), - [ - 'deck-card' => [ - 'type' => 'deck-card', - 'id' => (string)$cardId, - 'name' => $params[0], - 'boardname' => (string)$params[1], - 'stackname' => $stack->getTitle(), - 'link' => $this->getCardUrl($boardId, $cardId), - ], - 'deck-board' => [ - 'type' => 'deck-board', - 'id' => (string)$boardId, - 'name' => (string)$params[1], - 'link' => $this->getBoardUrl($boardId), - ], - 'user' => [ - 'type' => 'user', - 'id' => (string)$params[2], - 'name' => $dn, - ] + ] + ); + } else { + $notification->setParsedSubject( + $l->t('The card "%s" on "%s" has been assigned to you by %s.', [$params[0], $params[1], $dn]) + ); + $notification->setRichSubject( + $l->t('{user} has assigned the card {deck-card} on {deck-board} to you.'), + [ + 'deck-card' => [ + 'type' => 'deck-card', + 'id' => (string)$cardId, + 'name' => $params[0], + 'boardname' => (string)$params[1], + 'stackname' => $stack->getTitle(), + 'link' => $this->getCardUrl($boardId, $cardId), + ], + 'deck-board' => [ + 'type' => 'deck-board', + 'id' => (string)$boardId, + 'name' => (string)$params[1], + 'link' => $this->getBoardUrl($boardId), + ], + 'user' => [ + 'type' => 'user', + 'id' => (string)$params[2], + 'name' => $dn, ] - ); - } + ] + ); + } $notification->setLink($this->getCardUrl($boardId, $cardId)); break; case 'card-overdue': diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index f625d8cce1..5b10c00669 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -579,7 +579,7 @@ public function clone( 'PERMISSION_EDIT' => $permissions[Acl::PERMISSION_EDIT] ?? false, 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, - 'PERMISSION_OWNER' => $permissions[Board::PERMISSION_OWNER] ?? false, + 'PERMISSION_OWNER' => $permissions[Board::PERMISSION_OWNER] ?? false, ]); $this->boardMapper->insert($newBoard); diff --git a/lib/Service/CirclesService.php b/lib/Service/CirclesService.php index f557e3566d..7402a11b75 100644 --- a/lib/Service/CirclesService.php +++ b/lib/Service/CirclesService.php @@ -31,7 +31,10 @@ class CirclesService { /** @var array */ private array $userCirclesCache = []; - public function __construct(IAppManager $appManager, private ?LoggerInterface $logger = null) { + public function __construct( + IAppManager $appManager, + private ?LoggerInterface $logger = null, + ) { $this->circlesEnabled = $appManager->isEnabledForUser('circles'); } From 3362e487402e5fcbbabc9388bf79d9008dbaa9c2 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Mon, 4 May 2026 15:02:18 +0200 Subject: [PATCH 23/27] Fix Psalm errors in circle ownership feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stub.phpstub: add getUserId() to Member stub (was missing, caused UndefinedMethod) - TransferOwnership: remove redundant double-check of $circle !== null; restructure to single if/else so psalm tracks $circleExists cleanly - TransferOwnership: suppress RedundantCondition on the ownerCircleExists and circleExists guards — psalm traces probeCircle() stub as non-nullable and loses the exception path - CirclesService: suppress RedundantCondition on $userId !== null guard in clearUserCircleCache — prior guard makes it implied but explicit is clearer Co-Authored-By: Claude Sonnet 4.6 --- lib/Command/TransferOwnership.php | 6 +++++- lib/Service/CirclesService.php | 1 + tests/stub.phpstub | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/Command/TransferOwnership.php b/lib/Command/TransferOwnership.php index 9e42acdf43..e8b4966044 100644 --- a/lib/Command/TransferOwnership.php +++ b/lib/Command/TransferOwnership.php @@ -107,6 +107,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + /** @psalm-suppress RedundantCondition -- psalm traces probeCircle() stub as non-nullable and misses the exception path */ if ($ownerCircleExists && !$ownerUserExists) { $ownerType = Acl::PERMISSION_TYPE_CIRCLE; } @@ -130,9 +131,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($this->circlesService->isCirclesEnabled()) { try { $circle = $this->circlesService->getCircle($newOwner); - $circleExists = $circle !== null; if ($circle !== null) { + $circleExists = true; $teamDisplayName = $circle->getDisplayName(); + } else { + $circleExists = false; } } catch (\Throwable $e) { $circleExists = false; @@ -144,6 +147,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + /** @psalm-suppress RedundantCondition -- psalm traces probeCircle() stub as non-nullable and misses the exception path */ if ($circleExists && !$userExists) { $newOwnerType = Acl::PERMISSION_TYPE_CIRCLE; $output->writeln('Detected team target: treating ' . $newOwner . ' as team ' . ($teamDisplayName ?: $newOwner) . '.'); diff --git a/lib/Service/CirclesService.php b/lib/Service/CirclesService.php index 7402a11b75..8883b0890d 100644 --- a/lib/Service/CirclesService.php +++ b/lib/Service/CirclesService.php @@ -154,6 +154,7 @@ public function clearUserCircleCache(?string $circleId = null, ?string $userId = return; } + /** @psalm-suppress RedundantCondition -- $userId !== null is implied by the prior guard but kept explicit for clarity */ if ($circleId !== null && $userId !== null) { unset($this->userCircleCache[$circleId][$userId]); if (empty($this->userCircleCache[$circleId])) { diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 8ca73d9dd8..163613fb77 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -72,7 +72,8 @@ namespace OCA\Circles\Model { public const APP_DEFAULT = 11000; public function getLevel(): int {} - public function getUserType(): int{} + public function getUserType(): int {} + public function getUserId(): string {} } class Circle { From fae6d6e6c0720e37de10114099d1a3e9026c3895 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Tue, 5 May 2026 07:07:46 +0200 Subject: [PATCH 24/27] Fix PHPUnit 11 and ESLint compatibility in test suite - Replace all withConsecutive() calls (removed in PHPUnit 10+) with willReturnCallback/willReturnArgument/willReturn equivalents across ActivityManagerTest, AttachmentServiceTest, BoardImportTest, BoardImportServiceTest, BoardServiceTest, CardApiControllerTest, DefaultBoardServiceTest, DeleteCronTest, NotificationHelperTest, PermissionServiceTest, StackApiControllerTest - Remove addWarning() calls (removed in PHPUnit 11) from ActivityManagerTest - Add retainsAccessViaMembership field to expected arrays in AclTest - Add CirclesService mock as 5th constructor arg in NotificationHelperTest - Rewrite CirclesServiceTest to use probeCircles/probeCircle and onlyMethods() instead of addMethods() for stopSession - Fix boardMapper->find call count (4) in testTransferBoardOwnershipToCircle - Fix find call count (1) in testUserIsBoardOwnerCircleMember (board cached) - Fix testFindUsers: use real Board and willReturnCallback for userExists - Add ownerType and retainsAccessViaMembership to ImportExportTest skipKeyList - Fix vue/first-attribute-linebreak on NcActionButton in SharingTabSidebar.vue - Add runtests.sh to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + src/components/board/SharingTabSidebar.vue | 3 +- tests/integration/import/ImportExportTest.php | 2 +- tests/unit/Activity/ActivityManagerTest.php | 8 --- tests/unit/Command/BoardImportTest.php | 13 ++-- tests/unit/Cron/DeleteCronTest.php | 14 +---- tests/unit/Db/AclTest.php | 9 ++- .../Notification/NotificationHelperTest.php | 38 +++++------ tests/unit/Service/AttachmentServiceTest.php | 63 +++++++------------ tests/unit/Service/BoardServiceTest.php | 37 +++++------ tests/unit/Service/CirclesServiceTest.php | 21 ++++--- .../unit/Service/DefaultBoardServiceTest.php | 32 ++++------ .../Importer/BoardImportServiceTest.php | 13 ++-- tests/unit/Service/PermissionServiceTest.php | 24 +++---- .../unit/controller/CardApiControllerTest.php | 11 ++-- .../controller/StackApiControllerTest.php | 10 +-- 16 files changed, 118 insertions(+), 181 deletions(-) diff --git a/.gitignore b/.gitignore index 087b37fd2b..f028d5b32e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ vendor/ .php-cs-fixer.cache \.idea/ settings.json +runtests.sh diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index defdf8d34f..963bd02aaf 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -24,8 +24,7 @@ - {{ t('deck', 'Transfer ownership to myself') }} diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index 247830e06a..40717e816d 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -134,7 +134,7 @@ public function testReimportOcc() { ); } - public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token']): string { + public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token', 'ownerType', 'retainsAccessViaMembership']): string { $output = ''; $arrayIsList = array_keys($array) === range(0, count($array) - 1); foreach ($array as $key => $value) { diff --git a/tests/unit/Activity/ActivityManagerTest.php b/tests/unit/Activity/ActivityManagerTest.php index 915f7b2b90..910831b215 100644 --- a/tests/unit/Activity/ActivityManagerTest.php +++ b/tests/unit/Activity/ActivityManagerTest.php @@ -103,14 +103,10 @@ public function testGetActivityFormatOwn() { $format = $this->activityManager->getActivityFormat('cz', $value, [], false); if ($format !== '') { $this->assertStringContainsString('{user}', $format); - } else { - self::addWarning('No activity string found for ' . $constant); } $format = $this->activityManager->getActivityFormat('cz', $value, [], true); if ($format !== '') { $this->assertStringStartsWith('You', $format); - } else { - self::addWarning('No own activity string found for ' . $constant); } } } @@ -325,10 +321,6 @@ public function testSendToUser($objectType) { ->willReturn(1); $event->expects(self::exactly(2)) ->method('setAffectedUser') - ->withConsecutive( - ['user1'], - ['user2'], - ) ->willReturnSelf(); $mapper = null; diff --git a/tests/unit/Command/BoardImportTest.php b/tests/unit/Command/BoardImportTest.php index db63263083..3113cc16b3 100644 --- a/tests/unit/Command/BoardImportTest.php +++ b/tests/unit/Command/BoardImportTest.php @@ -54,14 +54,11 @@ public function testExecuteWithSuccess() { $input = $this->createMock(InputInterface::class); $input ->method('getOption') - ->withConsecutive( - ['system'], - ['config'] - ) - ->will($this->returnValueMap([ - ['system', 'trelloJson'], - ['config', null] - ])); + ->willReturnCallback(fn ($key) => match($key) { + 'system' => 'trelloJson', + 'config' => null, + default => null, + }); $output = $this->createMock(OutputInterface::class); diff --git a/tests/unit/Cron/DeleteCronTest.php b/tests/unit/Cron/DeleteCronTest.php index 73ab3a416a..1b401f4349 100644 --- a/tests/unit/Cron/DeleteCronTest.php +++ b/tests/unit/Cron/DeleteCronTest.php @@ -108,13 +108,7 @@ public function testDeleteCron() { ->method('findToDelete') ->willReturn($boards); $this->boardMapper->expects($this->exactly(count($boards))) - ->method('delete') - ->withConsecutive( - [$boards[0]], - [$boards[1]], - [$boards[2]], - [$boards[3]] - ); + ->method('delete'); $cards = [ $this->getCard(10) ]; $this->cardMapper->expects($this->once()) @@ -150,11 +144,7 @@ public function testDeleteCron() { ->method('findToDelete') ->willReturn($stacks); $this->stackMapper->expects($this->exactly(count($stacks))) - ->method('delete') - ->withConsecutive( - [$stacks[0]], - [$stacks[1]] - ); + ->method('delete'); $this->invokePrivate($this->deleteCron, 'run', [null]); } diff --git a/tests/unit/Db/AclTest.php b/tests/unit/Db/AclTest.php index 06953b9838..a89f57ef5c 100644 --- a/tests/unit/Db/AclTest.php +++ b/tests/unit/Db/AclTest.php @@ -59,7 +59,8 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'retainsAccessViaMembership' => false, ], $acl->jsonSerialize()); $acl = $this->createAclGroup(); $this->assertEquals([ @@ -70,7 +71,8 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'retainsAccessViaMembership' => false, ], $acl->jsonSerialize()); } @@ -85,7 +87,8 @@ public function testSetOwner() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => true + 'owner' => true, + 'retainsAccessViaMembership' => false, ], $acl->jsonSerialize()); } diff --git a/tests/unit/Notification/NotificationHelperTest.php b/tests/unit/Notification/NotificationHelperTest.php index 36cb9bb55e..427427debf 100644 --- a/tests/unit/Notification/NotificationHelperTest.php +++ b/tests/unit/Notification/NotificationHelperTest.php @@ -31,6 +31,7 @@ use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\User; +use OCA\Deck\Service\CirclesService; use OCA\Deck\Service\ConfigService; use OCA\Deck\Service\PermissionService; use OCP\Comments\IComment; @@ -65,6 +66,8 @@ class NotificationHelperTest extends \Test\TestCase { protected $assignedUsersMapper; /** @var PermissionService|MockObject */ protected $permissionService; + /** @var CirclesService|MockObject */ + protected $circlesService; /** @var IConfig|MockObject */ protected $config; /** @var IManager|MockObject */ @@ -82,6 +85,7 @@ public function setUp(): void { $this->boardMapper = $this->createMock(BoardMapper::class); $this->assignedUsersMapper = $this->createMock(AssignmentMapper::class); $this->permissionService = $this->createMock(PermissionService::class); + $this->circlesService = $this->createMock(CirclesService::class); $this->config = $this->createMock(IConfig::class); $this->notificationManager = $this->createMock(IManager::class); $this->groupManager = $this->createMock(IGroupManager::class); @@ -91,6 +95,7 @@ public function setUp(): void { $this->boardMapper, $this->assignedUsersMapper, $this->permissionService, + $this->circlesService, $this->config, $this->notificationManager, $this->groupManager, @@ -123,11 +128,6 @@ public function testSendCardDuedate() { $this->config->expects($this->exactly(3)) ->method('getUserValue') - ->withConsecutive( - [$param1[0], $param2, $param3, $DUE_ASSIGNED], - [$param1[1], $param2, $param3, $DUE_ASSIGNED], - [$param1[2], $param2, $param3, $DUE_ASSIGNED], - ) ->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ALL); $card = Card::fromParams([ @@ -187,8 +187,7 @@ public function testSendCardDuedate() { ->method('createNotification') ->willReturnOnConsecutiveCalls($n1, $n2, $n3); $this->notificationManager->expects($this->exactly(3)) - ->method('notify') - ->withConsecutive([$n1], [$n2], [$n3]); + ->method('notify'); $this->cardMapper->expects($this->once()) ->method('markNotified') @@ -205,11 +204,6 @@ public function testSendCardDuedateAssigned() { $this->config->expects($this->exactly(3)) ->method('getUserValue') - ->withConsecutive( - [$param1[0], $param2, $param3, $DUE_ASSIGNED], - [$param1[1], $param2, $param3, $DUE_ASSIGNED], - [$param1[2], $param2, $param3, $DUE_ASSIGNED] - ) ->willReturn($DUE_ASSIGNED); $users = [ @@ -275,8 +269,7 @@ public function testSendCardDuedateAssigned() { ->method('createNotification') ->willReturnOnConsecutiveCalls($n1, $n2, $n3); $this->notificationManager->expects($this->exactly(3)) - ->method('notify') - ->withConsecutive([$n1], [$n2], [$n3]); + ->method('notify'); $this->cardMapper->expects($this->once()) ->method('markNotified') @@ -293,14 +286,13 @@ public function testSendCardDuedateNever() { $DUE_ASSIGNED = ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED; $DUE_OFF = ConfigService::SETTING_BOARD_NOTIFICATION_DUE_OFF; + $dueCalls = [$DUE_ASSIGNED, $DUE_ASSIGNED, $DUE_OFF]; + $dueIndex = 0; $this->config->expects($this->exactly(3)) ->method('getUserValue') - ->withConsecutive( - [$param1[0], $param2, $param3, $DUE_ASSIGNED], - [$param1[1], $param2, $param3, $DUE_ASSIGNED], - [$param1[2], $param2, $param3, $DUE_ASSIGNED] - ) - ->willReturnOnConsecutiveCalls($DUE_ASSIGNED, $DUE_ASSIGNED, $DUE_OFF); + ->willReturnCallback(function () use (&$dueIndex, $dueCalls) { + return $dueCalls[$dueIndex++]; + }); $users = [ new DummyUser('foo'), new DummyUser('bar'), new DummyUser('asd') @@ -358,8 +350,7 @@ public function testSendCardDuedateNever() { ->method('createNotification') ->willReturnOnConsecutiveCalls($n1, $n2); $this->notificationManager->expects($this->exactly(2)) - ->method('notify') - ->withConsecutive([$n1], [$n2]); + ->method('notify'); $this->cardMapper->expects($this->once()) ->method('markNotified') @@ -522,8 +513,7 @@ public function testSendMention() { ->method('createNotification') ->willReturnOnConsecutiveCalls($notification1, $notification2); $this->notificationManager->expects($this->exactly(2)) - ->method('notify') - ->withConsecutive([$notification1], [$notification2]); + ->method('notify'); $this->notificationHelper->sendMention($comment); } diff --git a/tests/unit/Service/AttachmentServiceTest.php b/tests/unit/Service/AttachmentServiceTest.php index 638819073b..1de9949498 100644 --- a/tests/unit/Service/AttachmentServiceTest.php +++ b/tests/unit/Service/AttachmentServiceTest.php @@ -119,14 +119,11 @@ public function setUp(): void { $this->appContainer->expects($this->exactly(2)) ->method('get') - ->withConsecutive( - [FileService::class], - [FilesAppService::class] - ) - ->willReturnOnConsecutiveCalls( - $this->attachmentServiceImpl, - $this->filesAppServiceImpl - ); + ->willReturnCallback(fn ($class) => match($class) { + FileService::class => $this->attachmentServiceImpl, + FilesAppService::class => $this->filesAppServiceImpl, + default => null, + }); $this->application->expects($this->any()) ->method('getContainer') @@ -157,18 +154,15 @@ public function testRegisterAttachmentService() { $fileServiceMock = $this->createMock(FileService::class); $fileAppServiceMock = $this->createMock(FilesAppService::class); + $myAttachmentService = new MyAttachmentService(); $appContainer->expects($this->exactly(3)) ->method('get') - ->withConsecutive( - [FileService::class], - [FilesAppService::class], - [MyAttachmentService::class] - ) - ->willReturnOnConsecutiveCalls( - $fileServiceMock, - $fileAppServiceMock, - new MyAttachmentService() - ); + ->willReturnCallback(fn ($class) => match($class) { + FileService::class => $fileServiceMock, + FilesAppService::class => $fileAppServiceMock, + MyAttachmentService::class => $myAttachmentService, + default => null, + }); $application->expects($this->any()) ->method('getContainer') @@ -186,18 +180,15 @@ public function testRegisterAttachmentServiceNotExisting() { $fileServiceMock = $this->createMock(FileService::class); $fileAppServiceMock = $this->createMock(FilesAppService::class); + $myAttachmentService2 = new MyAttachmentService(); $appContainer->expects($this->exactly(3)) ->method('get') - ->withConsecutive( - [FileService::class], - [FilesAppService::class], - [MyAttachmentService::class] - ) - ->willReturnOnConsecutiveCalls( - $fileServiceMock, - $fileAppServiceMock, - new MyAttachmentService() - ); + ->willReturnCallback(fn ($class) => match($class) { + FileService::class => $fileServiceMock, + FilesAppService::class => $fileAppServiceMock, + MyAttachmentService::class => $myAttachmentService2, + default => null, + }); $application->expects($this->any()) ->method('getContainer') @@ -236,14 +227,7 @@ public function testFindAll() { $this->attachmentServiceImpl->expects($this->exactly(2)) ->method('extendData') - ->withConsecutive( - [$attachments[0]], - [$attachments[1]], - ) - ->willReturnOnConsecutiveCalls( - $attachments[0], - $attachments[1], - ); + ->willReturnCallback(fn ($a) => $a); $this->assertEquals($attachments, $this->attachmentService->findAll(123, false)); } @@ -271,12 +255,7 @@ public function testFindAllWithDeleted() { $this->attachmentServiceImpl->expects($this->exactly(4)) ->method('extendData') - ->withConsecutive( - [$attachments[0]], - [$attachments[1]], - [$attachmentsDeleted[0]], - [$attachmentsDeleted[1]] - ); + ->willReturnArgument(0); $this->assertEquals(array_merge($attachments, $attachmentsDeleted), $this->attachmentService->findAll(123, true)); } diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 9299ef2052..0799807d98 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -391,27 +391,22 @@ public function testAddAclExtendPermission($currentUserAcl, $providedAcl, $resul if ($currentUserAcl[2]) { $this->permissionService->expects($this->exactly(2)) ->method('checkPermission') - ->withConsecutive( - [$this->boardMapper, 123, Acl::PERMISSION_SHARE, null], - [$this->boardMapper, 123, Acl::PERMISSION_MANAGE, null] - ); + ->willReturn(true); } else { $this->aclMapper->expects($this->once()) ->method('findAll') ->willReturn([$existingAcl]); + $callCount = 0; $this->permissionService->expects($this->exactly(2)) ->method('checkPermission') - ->withConsecutive( - [$this->boardMapper, 123, Acl::PERMISSION_SHARE, null], - [$this->boardMapper, 123, Acl::PERMISSION_MANAGE, null] - ) - ->will( - $this->onConsecutiveCalls( - true, - $this->throwException(new NoPermissionException('No permission')) - ) - ); + ->willReturnCallback(function ($mapper, $boardId, $permission) use (&$callCount) { + $callCount++; + if ($callCount === 2) { + throw new NoPermissionException('No permission'); + } + return true; + }); $this->permissionService->expects($this->exactly(3)) ->method('userCan') @@ -625,7 +620,11 @@ public function testFindMarksUserAclAsRetainedViaOtherAclMembership(): void { $this->boardMapper->expects($this->exactly(2)) ->method('mapAcl') - ->withConsecutive([$userAcl], [$groupAcl]); + ->willReturnCallback(fn ($acl) => match(true) { + $acl === $userAcl => null, + $acl === $groupAcl => null, + default => null, + }); $this->circlesService->expects($this->never()) ->method('isUserInCircle'); @@ -725,9 +724,9 @@ public function testTransferBoardOwnershipToCircle(): void { $this->connection->expects($this->once())->method('beginTransaction'); $this->connection->expects($this->once())->method('commit'); - $this->boardMapper->expects($this->exactly(2)) + $this->boardMapper->expects($this->exactly(4)) ->method('find') - ->willReturnOnConsecutiveCalls($board, $updatedBoard); + ->willReturnOnConsecutiveCalls($board, $board, $board, $updatedBoard); $this->circlesService->expects($this->once()) ->method('isCirclesEnabled') @@ -742,9 +741,7 @@ public function testTransferBoardOwnershipToCircle(): void { ->with(10, Acl::PERMISSION_TYPE_CIRCLE, 'circle-id-xyz'); // Previous user owner gets an ACL entry when changeContent = false - $this->aclMapper->expects($this->exactly(2)) - ->method('findAll') - ->willReturn([]); + $this->aclMapper->method('findAll')->willReturn([]); $this->aclMapper->expects($this->exactly(2)) ->method('insert') ->willReturnCallback(fn ($acl) => $acl); diff --git a/tests/unit/Service/CirclesServiceTest.php b/tests/unit/Service/CirclesServiceTest.php index 3c6b96e603..ab34ed1f77 100644 --- a/tests/unit/Service/CirclesServiceTest.php +++ b/tests/unit/Service/CirclesServiceTest.php @@ -13,6 +13,8 @@ use OCA\Circles\Model\Circle; use OCA\Circles\Model\FederatedUser; use OCA\Circles\Model\Member; +use OCA\Circles\Model\Probes\CircleProbe; +use OCA\Circles\Model\Probes\DataProbe; use OCP\App\IAppManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -35,8 +37,7 @@ public function testGetUserCirclesStopsSessionOnSuccess(): void { $federatedUser = $this->createMock(FederatedUser::class); $manager = $this->getMockBuilder(CirclesManager::class) ->disableOriginalConstructor() - ->onlyMethods(['getFederatedUser', 'startSession', 'getCircles']) - ->addMethods(['stopSession']) + ->onlyMethods(['getFederatedUser', 'startSession', 'probeCircles', 'stopSession']) ->getMock(); $manager->expects($this->once()) @@ -47,7 +48,8 @@ public function testGetUserCirclesStopsSessionOnSuccess(): void { ->method('startSession') ->with($federatedUser); $manager->expects($this->once()) - ->method('getCircles') + ->method('probeCircles') + ->with($this->isInstanceOf(CircleProbe::class)) ->willReturn([$circle]); $manager->expects($this->once()) ->method('stopSession'); @@ -68,8 +70,7 @@ public function testGetUserCirclesStopsSessionOnFailure(): void { $federatedUser = $this->createMock(FederatedUser::class); $manager = $this->getMockBuilder(CirclesManager::class) ->disableOriginalConstructor() - ->onlyMethods(['getFederatedUser', 'startSession', 'getCircles']) - ->addMethods(['stopSession']) + ->onlyMethods(['getFederatedUser', 'startSession', 'probeCircles', 'stopSession']) ->getMock(); $manager->expects($this->once()) @@ -80,7 +81,8 @@ public function testGetUserCirclesStopsSessionOnFailure(): void { ->method('startSession') ->with($federatedUser); $manager->expects($this->once()) - ->method('getCircles') + ->method('probeCircles') + ->with($this->isInstanceOf(CircleProbe::class)) ->willThrowException(new \RuntimeException('Boom')); $manager->expects($this->once()) ->method('stopSession'); @@ -100,15 +102,14 @@ public function testGetCircleStopsSuperSession(): void { $circle = $this->createMock(Circle::class); $manager = $this->getMockBuilder(CirclesManager::class) ->disableOriginalConstructor() - ->onlyMethods(['startSuperSession', 'getCircle']) - ->addMethods(['stopSession']) + ->onlyMethods(['startSuperSession', 'probeCircle', 'stopSession']) ->getMock(); $manager->expects($this->once()) ->method('startSuperSession'); $manager->expects($this->once()) - ->method('getCircle') - ->with('circle-1') + ->method('probeCircle') + ->with('circle-1', null, $this->isInstanceOf(DataProbe::class)) ->willReturn($circle); $manager->expects($this->once()) ->method('stopSession'); diff --git a/tests/unit/Service/DefaultBoardServiceTest.php b/tests/unit/Service/DefaultBoardServiceTest.php index 78011637c3..4a03be9984 100644 --- a/tests/unit/Service/DefaultBoardServiceTest.php +++ b/tests/unit/Service/DefaultBoardServiceTest.php @@ -152,13 +152,12 @@ public function testCreateDefaultBoard() { $this->stackService->expects($this->exactly(4)) ->method('create') - ->withConsecutive( - [$this->l10n->t('Custom lists - click to rename!'), $boardId, 0], - [$this->l10n->t('To Do'), $boardId, 1], - [$this->l10n->t('In Progress'), $boardId, 2], - [$this->l10n->t('Done'), $boardId, 3] - ) - ->willReturnOnConsecutiveCalls($stackCustom, $stackToDo, $stackDoing, $stackDone); + ->willReturnCallback(fn ($title, $bid, $order) => match($order) { + 0 => $stackCustom, + 1 => $stackToDo, + 2 => $stackDoing, + 3 => $stackDone, + }); $cardExampleTask1 = $this->assembleTestCard( '1. Open to learn more about boards and cards', @@ -186,22 +185,13 @@ public function testCreateDefaultBoard() { $this->userId ); + $cardResults = [$cardExampleTask1, $cardExampleTask2, $cardExampleTask3, $cardExampleTask4, $cardExampleTask5]; + $cardIndex = 0; $this->cardService->expects($this->exactly(5)) ->method('create') - ->withConsecutive( - ['1. Open to learn more about boards and cards', $stackCustomId, 'text', 0, $this->userId], - ['2. Drag cards left and right, up and down', $stackToDoId, 'text', 0, $this->userId], - ['Create your first card!', $stackToDoId, 'text', 1, $this->userId], - ['3. Apply rich formatting and link content', $stackDoingId, 'text', 0, $this->userId], - ['4. Share, comment and collaborate!', $stackDoneId, 'text', 0, $this->userId] - ) - ->willReturnonConsecutiveCalls( - $cardExampleTask1, - $cardExampleTask2, - $cardExampleTask3, - $cardExampleTask4, - $cardExampleTask5 - ); + ->willReturnCallback(function () use (&$cardIndex, $cardResults) { + return $cardResults[$cardIndex++]; + }); $result = $this->service->createDefaultBoard($title, $this->userId, $color); diff --git a/tests/unit/Service/Importer/BoardImportServiceTest.php b/tests/unit/Service/Importer/BoardImportServiceTest.php index 4cbaeb9fce..1730f4fc28 100644 --- a/tests/unit/Service/Importer/BoardImportServiceTest.php +++ b/tests/unit/Service/Importer/BoardImportServiceTest.php @@ -142,14 +142,11 @@ public function setUp(): void { ->willReturn('johndoe'); $this->userManager ->method('get') - ->withConsecutive( - ['admin'], - ['johndoe'] - ) - ->willReturnonConsecutiveCalls( - $owner, - $johndoe - ); + ->willReturnCallback(fn ($uid) => match($uid) { + 'admin' => $owner, + 'johndoe' => $johndoe, + default => null, + }); } public function testImportSuccess() { diff --git a/tests/unit/Service/PermissionServiceTest.php b/tests/unit/Service/PermissionServiceTest.php index cc9257386b..e15596216c 100644 --- a/tests/unit/Service/PermissionServiceTest.php +++ b/tests/unit/Service/PermissionServiceTest.php @@ -144,8 +144,10 @@ public function testUserIsBoardOwner() { $this->boardMapper->expects($this->exactly(2)) ->method('find') - ->withConsecutive([123], [234]) - ->willReturnOnConsecutiveCalls($adminBoard, $userBoard); + ->willReturnCallback(fn ($id) => match($id) { + 123 => $adminBoard, + 234 => $userBoard, + }); $this->assertEquals(true, $this->service->userIsBoardOwner(123)); $this->assertEquals(false, $this->service->userIsBoardOwner(234)); @@ -161,7 +163,7 @@ public function testUserIsBoardOwnerCircleMember() { $board->setOwner('circle-id-abc'); $board->setOwnerType(Acl::PERMISSION_TYPE_CIRCLE); - $this->boardMapper->expects($this->exactly(2)) + $this->boardMapper->expects($this->once()) ->method('find') ->willReturn($board); @@ -368,11 +370,8 @@ public function testFindUsers() { $aclGroup->setType(Acl::PERMISSION_TYPE_GROUP); $aclGroup->setParticipant('group1'); - $board = $this->createMock(Board::class); - $board->expects($this->any()) - ->method('__call') - ->with('getOwner', []) - ->willReturn('user1'); + $board = new Board(); + $board->setOwner('user1'); $this->aclMapper->expects($this->once()) ->method('findAll') ->with(123) @@ -381,10 +380,13 @@ public function testFindUsers() { ->method('find') ->with(123) ->willReturn($board); - $this->userManager->expects($this->any()) + $this->userManager->expects($this->exactly(2)) ->method('userExists') - ->withConsecutive(['user1'], ['user2']) - ->willReturnOnConsecutiveCalls($user1, $user2); + ->willReturnCallback(fn ($uid) => match($uid) { + 'user1' => true, + 'user2' => true, + default => false, + }); $group = $this->createMock(IGroup::class); $group->expects($this->once()) diff --git a/tests/unit/controller/CardApiControllerTest.php b/tests/unit/controller/CardApiControllerTest.php index c466b9614f..1b25150ed4 100644 --- a/tests/unit/controller/CardApiControllerTest.php +++ b/tests/unit/controller/CardApiControllerTest.php @@ -107,12 +107,11 @@ public function testUpdate() { $this->request->expects($this->exactly(2)) ->method('getParam') - ->withConsecutive( - ['cardId'], - ['stackId'] - )->willReturnonConsecutiveCalls( - $this->cardExample['id'], - $this->stackExample['id']); + ->willReturnCallback(fn ($key) => match($key) { + 'cardId' => $this->cardExample['id'], + 'stackId' => $this->stackExample['id'], + default => null, + }); $this->cardService->expects($this->once()) ->method('update') diff --git a/tests/unit/controller/StackApiControllerTest.php b/tests/unit/controller/StackApiControllerTest.php index 226b691f85..c5edb8f1a1 100644 --- a/tests/unit/controller/StackApiControllerTest.php +++ b/tests/unit/controller/StackApiControllerTest.php @@ -129,11 +129,11 @@ public function testCreate() { public function testUpdate() { $this->request->expects($this->exactly(2)) ->method('getParam') - ->withConsecutive( - ['stackId'], - ['boardId'] - ) - ->willReturnonConsecutiveCalls($this->exampleStack['id'], $this->exampleBoard['boardId']); + ->willReturnCallback(fn ($key) => match($key) { + 'stackId' => $this->exampleStack['id'], + 'boardId' => $this->exampleBoard['boardId'], + default => null, + }); $stack = new Stack(); $stack->setId($this->exampleStack['id']); From d4366f257283163de899cbac35d0368fa2cf7355 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Tue, 5 May 2026 14:47:33 +0200 Subject: [PATCH 25/27] Fix Cypress CI: read Node/npm version from package.json engines The cypress-e2e workflow was hardcoded to Node 20.x (npm 10), while package.json requires node '^24' and npm '^11.3'. npm 10 enforces optional peer deps (pinia@3.0.4 pulled in by vue-router inside @nextcloud/password-confirmation) that npm 11 handles transparently, causing `npm ci` to fail with "Missing from lock file" errors. Align with node.yml by reading node/npm versions from the package.json engines field via skjnldsv/read-package-engines-version-actions, placed after the app checkout (which is needed to read the package.json). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/cypress-e2e.yml | 22 +++-- package-lock.json | 134 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml index 12db70aac6..e8f6fa69f1 100644 --- a/.github/workflows/cypress-e2e.yml +++ b/.github/workflows/cypress-e2e.yml @@ -22,7 +22,6 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20.x] containers: [1, 2, 3] php-versions: [ '8.2' ] server-versions: [ 'master' ] @@ -41,11 +40,6 @@ jobs: options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5 steps: - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: ${{ matrix.node-version }} - - name: Register text Git reference run: | text_app_ref="$(if [ "${{ matrix.server-versions }}" = "master" ]; then echo -n "main"; else echo -n "${{ matrix.server-versions }}"; fi)" @@ -65,6 +59,22 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 + id: versions + with: + fallbackNode: '^24' + fallbackNpm: '^11.3' + path: apps/${{ env.APP_NAME }} + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' + - name: Checkout text uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/package-lock.json b/package-lock.json index a9421bae5b..271b222139 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4750,6 +4750,34 @@ "@vue/shared": "3.5.30" } }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@nextcloud/password-confirmation/node_modules/@vue/server-renderer": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", @@ -4945,6 +4973,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@nextcloud/password-confirmation/node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@nextcloud/password-confirmation/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -4957,6 +4993,40 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nextcloud/password-confirmation/node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@nextcloud/password-confirmation/node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/@nextcloud/password-confirmation/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -9775,6 +9845,23 @@ "dev": true, "peer": true }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/core-js": { "version": "2.6.9", "hasInstallScript": true, @@ -15383,6 +15470,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-whitespace": { "version": "0.3.0", "dev": true, @@ -19013,6 +19114,14 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -22585,6 +22694,17 @@ "wbuf": "^1.7.3" } }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spec-change": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/spec-change/-/spec-change-1.11.21.tgz", @@ -23745,6 +23865,20 @@ "node": ">=18" } }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/superstruct": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", From a00c00a78e825d758cdaef2501b3341890df6b4b Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Tue, 5 May 2026 14:48:01 +0200 Subject: [PATCH 26/27] Fix php-cs: correct indentation of closing ] in cloneBoard() Co-Authored-By: Claude Sonnet 4.6 EOF ) --- lib/Service/BoardService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 5b10c00669..40912e13fb 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -580,7 +580,7 @@ public function clone( 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false, 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false, 'PERMISSION_OWNER' => $permissions[Board::PERMISSION_OWNER] ?? false, - ]); + ]); $this->boardMapper->insert($newBoard); foreach ($this->aclMapper->findAll($board->getId()) as $acl) { From 3ef0a0a008b744082eec0a8986b3d7833410d9c7 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Tue, 5 May 2026 14:49:45 +0200 Subject: [PATCH 27/27] Fix PHPUnit: define Circles stubs when circles app is not installed in CI createMock() on OCA\Circles\Model\{Circle,Member,FederatedUser} and OCA\Circles\CirclesManager fails in CI environments where the circles app is not installed, because PHPUnit needs the class to exist to reflect on it before replacing all methods. Add tests/stubs.php with minimal stub implementations (guarded by class_exists checks so the real classes take precedence when circles is installed) and a tests/bootstrap-unit.php that loads both the Nextcloud server bootstrap and the stubs. Point phpunit.xml at the new bootstrap. Co-Authored-By: Claude Sonnet 4.6 --- tests/bootstrap-unit.php | 16 ++++++ tests/phpunit.xml | 2 +- tests/stubs.php | 117 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/bootstrap-unit.php create mode 100644 tests/stubs.php diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php new file mode 100644 index 0000000000..adefd227cc --- /dev/null +++ b/tests/bootstrap-unit.php @@ -0,0 +1,16 @@ + - + ./../lib diff --git a/tests/stubs.php b/tests/stubs.php new file mode 100644 index 0000000000..3981517d32 --- /dev/null +++ b/tests/stubs.php @@ -0,0 +1,117 @@ +