From ab9e69ccbb444976863013440040c4adf58c758d Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Tue, 3 Mar 2026 10:44:16 +0100 Subject: [PATCH 1/3] feat(federation): assign users Signed-off-by: grnd-alt --- appinfo/routes.php | 1 + lib/Controller/BoardOcsController.php | 2 - lib/Controller/CardOcsController.php | 15 +++++ lib/Db/Assignment.php | 1 + lib/Db/AssignmentMapper.php | 10 +++- lib/Db/FederatedUser.php | 8 +++ lib/Service/AssignmentService.php | 2 +- lib/Service/ExternalBoardService.php | 76 ++++++++++++++++++++++++ lib/Service/PermissionService.php | 7 +++ src/components/cards/AvatarList.vue | 6 ++ src/components/cards/CardMenuEntries.vue | 2 +- src/services/CardApi.js | 6 +- src/store/card.js | 3 +- src/store/main.js | 2 +- 14 files changed, 131 insertions(+), 10 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 0abe357ffb..fce9bb8381 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -142,6 +142,7 @@ ['name' => 'card_ocs#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'], ['name' => 'card_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'PUT'], ['name' => 'card_ocs#assignLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'POST'], + ['name' => 'card_ocs#assignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/assign', 'verb' => 'POST'], ['name' => 'card_ocs#removeLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card_ocs#reorder', 'url' => '/api/v{apiVersion}/cards/{cardId}/reorder', 'verb' => 'PUT'], diff --git a/lib/Controller/BoardOcsController.php b/lib/Controller/BoardOcsController.php index 3e9d4d254f..340a6c1ea6 100644 --- a/lib/Controller/BoardOcsController.php +++ b/lib/Controller/BoardOcsController.php @@ -41,12 +41,10 @@ public function index(): DataResponse { #[NoCSRFRequired] #[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)] public function read(int $boardId): DataResponse { - // Board on this instance -> get it from database $localBoard = $this->boardService->find($boardId, true, true); if ($localBoard->getExternalId() !== null) { return $this->externalBoardService->getExternalBoardFromRemote($localBoard); } - // Board on other instance -> get it from other instance return new DataResponse($localBoard); } diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php index 0381c1c6e6..c64e21119d 100644 --- a/lib/Controller/CardOcsController.php +++ b/lib/Controller/CardOcsController.php @@ -8,6 +8,7 @@ namespace OCA\Deck\Controller; use OCA\Deck\Model\OptionalNullableValue; +use OCA\Deck\Service\AssignmentService; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\CardService; use OCA\Deck\Service\ExternalBoardService; @@ -25,6 +26,7 @@ public function __construct( string $appName, IRequest $request, private CardService $cardService, + private AssignmentService $assignmentService, private StackService $stackService, private BoardService $boardService, private ExternalBoardService $externalBoardService, @@ -77,6 +79,19 @@ public function assignLabel(?int $boardId, int $cardId, int $labelId): DataRespo return new DataResponse($this->cardService->assignLabel($cardId, $labelId)); } + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + public function assignUser(?int $boardId, int $cardId, string $userId, int $type = 0): DataResponse { + if ($boardId) { + $localBoard = $this->boardService->find($boardId, false); + if ($localBoard->getExternalId()) { + return new DataResponse($this->externalBoardService->assignUserOnRemote($localBoard, $cardId, $userId, $type)); + } + } + return new DataResponse($this->assignmentService->assignUser($cardId, $userId, $type)); + } + #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] diff --git a/lib/Db/Assignment.php b/lib/Db/Assignment.php index 4160aed12d..ee4062bd10 100644 --- a/lib/Db/Assignment.php +++ b/lib/Db/Assignment.php @@ -17,6 +17,7 @@ class Assignment extends RelationalEntity implements JsonSerializable { public const TYPE_USER = Acl::PERMISSION_TYPE_USER; public const TYPE_GROUP = Acl::PERMISSION_TYPE_GROUP; public const TYPE_CIRCLE = Acl::PERMISSION_TYPE_CIRCLE; + public const TYPE_REMOTE = Acl::PERMISSION_TYPE_REMOTE; public function __construct() { $this->addType('id', 'integer'); diff --git a/lib/Db/AssignmentMapper.php b/lib/Db/AssignmentMapper.php index da1af6405a..8cbb43c61f 100644 --- a/lib/Db/AssignmentMapper.php +++ b/lib/Db/AssignmentMapper.php @@ -13,6 +13,7 @@ use OCA\Deck\Service\CirclesService; use OCP\AppFramework\Db\Entity; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Federation\ICloudIdManager; use OCP\IDBConnection; use OCP\IGroupManager; use OCP\IUserManager; @@ -29,13 +30,17 @@ class AssignmentMapper extends DeckMapper implements IPermissionMapper { /** @var CirclesService */ private $circleService; - public function __construct(IDBConnection $db, CardMapper $cardMapper, IUserManager $userManager, IGroupManager $groupManager, CirclesService $circleService) { + /** @var ICloudIdManager */ + private $cloudIdManager; + + public function __construct(IDBConnection $db, CardMapper $cardMapper, IUserManager $userManager, IGroupManager $groupManager, CirclesService $circleService, ICloudIdManager $cloudIdManager) { parent::__construct($db, 'deck_assigned_users', Assignment::class); $this->cardMapper = $cardMapper; $this->userManager = $userManager; $this->groupManager = $groupManager; $this->circleService = $circleService; + $this->cloudIdManager = $cloudIdManager; } public function findAll(int $cardId): array { @@ -175,6 +180,9 @@ private function getOrigin(Assignment $assignment) { $origin = $this->circleService->getCircle($assignment->getParticipant()); return $origin ? new Circle($origin) : null; } + if ($assignment->getType() === Assignment::TYPE_REMOTE) { + return new FederatedUser($this->cloudIdManager->resolveCloudId($assignment->getParticipant())); + } return null; } diff --git a/lib/Db/FederatedUser.php b/lib/Db/FederatedUser.php index c48c9eb81a..31f2bfff0d 100644 --- a/lib/Db/FederatedUser.php +++ b/lib/Db/FederatedUser.php @@ -17,6 +17,14 @@ public function __construct(ICloudId $cloudId) { parent::__construct($cloudId->getId(), $cloudId); } + public function getCloudId(): ICloudId { + return $this->cloudId; + } + + public function getUID(): string { + return $this->cloudId->getId(); + } + public function getObjectSerialization(): array { return [ 'uid' => $this->cloudId->getId(), diff --git a/lib/Service/AssignmentService.php b/lib/Service/AssignmentService.php index 4d9a97486d..89849ee471 100644 --- a/lib/Service/AssignmentService.php +++ b/lib/Service/AssignmentService.php @@ -99,7 +99,7 @@ public function __construct( public function assignUser(int $cardId, string $userId, int $type = Assignment::TYPE_USER): Assignment { $this->assignmentServiceValidator->check(compact('cardId', 'userId')); - if ($type !== Assignment::TYPE_USER && $type !== Assignment::TYPE_GROUP) { + if ($type !== Assignment::TYPE_USER && $type !== Assignment::TYPE_GROUP && $type !== Assignment::TYPE_REMOTE) { throw new BadRequestException('Invalid type provided for assignemnt'); } diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index 4af4cc8005..e6ce8478f5 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -7,13 +7,18 @@ namespace OCA\Deck\Service; +use OC\Federation\CloudIdManager; use OCA\Deck\Db\Acl; +use OCA\Deck\Db\Assignment; use OCA\Deck\Db\Board; use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\FederatedUser; +use OCA\Deck\Db\User; use OCA\Deck\Federation\DeckFederationProxy; use OCA\Deck\Model\OptionalNullableValue; use OCP\AppFramework\Http\DataResponse; use OCP\Federation\ICloudIdManager; +use OCP\IURLGenerator; use OCP\IUserManager; class ExternalBoardService { @@ -25,6 +30,7 @@ public function __construct( private BoardService $boardService, private PermissionService $permissionService, private BoardMapper $boardMapper, + private IURLGenerator $urlGenerator, private ?string $userId, ) { } @@ -50,9 +56,37 @@ public function getExternalStacksFromRemote(Board $localBoard):DataResponse { return new DataResponse($this->LocalizeRemoteStacks($ocs, $localBoard)); } + public function localizeRemoteUser(Board $localBoard, array $user): array | User | FederatedUser | null { + // skip invalid users + if (!$user['uid']) { + return null;; + } + // if it's already a valid cloud id the user originates from a third instance and we pass it as is + if ($this->cloudIdManager->isValidCloudId($user['uid'])) { + if ($user['remote'] == $this->urlGenerator->getBaseUrl()) { + // local user from remote: return as local user + $localuid = $this->cloudIdManager->resolveCloudId($user['uid'])->getUser(); + return new User($localuid, $this->userManager); + } + return new FederatedUser($this->cloudIdManager->resolveCloudId($user['uid'])); + } + // if it's not a valid cloud id: it originates from the remote instance and we send it out as a federated user + $owner = $localBoard->resolveOwner(); // retrieve owner to get the remote + if ($owner instanceof FederatedUser) { + return new FederatedUser($this->cloudIdManager->getCloudId($user['uid'], $owner->getCloudId()->getRemote())); + } + return null; + } + public function LocalizeRemoteStacks(array $stacks, Board $localBoard) { foreach ($stacks as $i => $stack) { $stack['boardId'] = $localBoard->getId(); + foreach($stack['cards'] as $j => $card) { + $stack['cards'][$j]['assignedUsers'] = array_map(function ($assignment) use ($localBoard) { + $assignment['participant'] = $this->localizeRemoteUser($localBoard, $assignment['participant']); + return $assignment; + }, $card['assignedUsers']); + } $stacks[$i] = $stack; } return $stacks; @@ -63,9 +97,19 @@ public function LocalizeRemoteBoard(array $remoteBoard, Board $localBoard) { $remoteBoard['owner'] = $localBoard->resolveOwner(); $remoteBoard['acl'] = $localBoard->getAcl(); $remoteBoard['permissions'] = $localBoard->getPermissions(); + $remoteBoard['users'] = $this->localizeRemoteUsers($remoteBoard['users'], $localBoard); return $remoteBoard; } + public function localizeRemoteUsers(array $users, Board $localBoard) { + $localizedUsers = []; + foreach ($users as $i => $user) { + $localizedUsers[] = $this->localizeRemoteUser($localBoard, $user); + } + + return $localizedUsers; + } + public function createCardOnRemote( Board $localBoard, string $title, @@ -159,6 +203,38 @@ public function removeLabelOnRemote(Board $localBoard, int $cardId, int $labelId return $this->proxy->getOcsData($resp); } + public function assignUserOnRemote(Board $localBoard, int $cardId, string $userId, int $type = 0): array { + $this->configService->ensureFederationEnabled(); + + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + + if ($this->cloudIdManager->isValidCloudId($userId)) { + $cloudId = $this->cloudIdManager->resolveCloudId($userId); + // assignee's origin is the same as the board owner's origin: send as local user + if ($cloudId->getRemote() === $ownerCloudId->getRemote()) { + $userId = $cloudId->getUser(); + $type = Assignment::TYPE_USER; + } + } else { + // local user for us = remote user for remote + $userId = $this->cloudIdManager->getCloudId($userId, null)->getId(); + $type = Assignment::TYPE_REMOTE; + } + $shareToken = $localBoard->getShareToken(); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/assign'; + $resp = $this->proxy->post($ownerCloudId->getId(), $shareToken, $url, [ + 'userId' => $userId, + 'type' => $type, + 'boardId' => $localBoard->getExternalId(), + ]); + $result = $this->proxy->getOcsData($resp); + if (isset($result['participant'])) { + $result['participant'] = $this->localizeRemoteUser($localBoard, $result['participant']); + } + return $result; + } + + public function createStackOnRemote( Board $localBoard, string $title, diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index ba99ec44ac..0f4f4c794c 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -13,12 +13,14 @@ use OCA\Deck\Db\Board; use OCA\Deck\Db\BoardMapper; use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\FederatedUser; use OCA\Deck\Db\IPermissionMapper; use OCA\Deck\Db\User; use OCA\Deck\NoPermissionException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\Cache\CappedMemoryCache; +use OCP\Federation\ICloudIdManager; use OCP\IConfig; use OCP\IGroupManager; use OCP\IUserManager; @@ -47,6 +49,7 @@ public function __construct( private IGroupManager $groupManager, private IManager $shareManager, private IConfig $config, + private ICloudIdManager $cloudIdManager, private ?string $userId, ) { $this->boardCache = new CappedMemoryCache(); @@ -306,6 +309,10 @@ public function findUsers($boardId, $refresh = false) { } } + if ($acl->getType() === Acl::PERMISSION_TYPE_REMOTE) { + $users[(string)$acl->getParticipant()] = new FederatedUser($this->cloudIdManager->resolveCloudId($acl->getParticipant())); + } + if ($this->circlesService->isCirclesEnabled() && $acl->getType() === Acl::PERMISSION_TYPE_CIRCLE) { try { $circle = $this->circlesService->getCircle($acl->getParticipant()); diff --git a/src/components/cards/AvatarList.vue b/src/components/cards/AvatarList.vue index a2f3abd688..22b4300f36 100644 --- a/src/components/cards/AvatarList.vue +++ b/src/components/cards/AvatarList.vue @@ -19,6 +19,12 @@ :disable-menu="true" :hide-status="true" :size="32" /> + item.type === 0 && item.participant.uid === getCurrentUser()?.uid) + return this.card.assignedUsers.find((item) => (item.type === 0 || item.type === 6) && item.participant.uid === getCurrentUser()?.uid) }, boardId() { return this.card?.boardId ? this.card.boardId : Number(this.$route.params.id) diff --git a/src/services/CardApi.js b/src/services/CardApi.js index e1e22f2142..c5a4b2309a 100644 --- a/src/services/CardApi.js +++ b/src/services/CardApi.js @@ -110,11 +110,11 @@ export class CardApi { }) } - assignUser(cardId, id, type) { - return axios.post(this.url(`/cards/${cardId}/assign`), { userId: id, type }) + assignUser(cardId, id, type, boardId) { + return axios.post(this.ocsUrl(`/cards/${cardId}/assign`), { userId: id, type, boardId }) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) diff --git a/src/store/card.js b/src/store/card.js index 56a1ef1cb7..f944da645f 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -354,7 +354,8 @@ export default function cardModuleFactory() { } }, async assignCardToUser({ commit }, { card, assignee }) { - const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type) + const boardId = this.state.currentBoard.id + const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type, boardId) commit('assignCardToUser', user) }, async removeUserFromCard({ commit }, { card, assignee }) { diff --git a/src/store/main.js b/src/store/main.js index d7a24eaf4d..9a45065b0e 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -87,7 +87,7 @@ export default function storeFactory() { }, assignables: state => { return [ - ...state.assignableUsers.map((user) => ({ ...user, type: 0 })), + ...state.assignableUsers.map((user) => ({ ...user, type: user.type })), ...state.currentBoard.acl.filter((acl) => acl.type === 1 && typeof acl.participant === 'object').map((group) => ({ ...group.participant, type: 1 })), ...state.currentBoard.acl.filter((acl) => acl.type === 7 && typeof acl.participant === 'object').map((circle) => ({ ...circle.participant, type: 7 })), ] From d20601adf69dad73ab6cdb9b26004bd097e60f2d Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Tue, 3 Mar 2026 11:06:21 +0100 Subject: [PATCH 2/3] feat(federation): unassign users Signed-off-by: grnd-alt --- appinfo/routes.php | 1 + lib/Controller/CardOcsController.php | 13 +++++ lib/Service/ExternalBoardService.php | 59 +++++++++++++------- lib/Service/PermissionService.php | 4 +- src/components/board/SharingTabSidebar.vue | 3 +- src/services/CardApi.js | 6 +- src/store/card.js | 3 +- tests/unit/Service/PermissionServiceTest.php | 4 ++ 8 files changed, 67 insertions(+), 26 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index fce9bb8381..685a6b8756 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -143,6 +143,7 @@ ['name' => 'card_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'PUT'], ['name' => 'card_ocs#assignLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'POST'], ['name' => 'card_ocs#assignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/assign', 'verb' => 'POST'], + ['name' => 'card_ocs#unAssignUser', 'url' => '/api/v{apiVersion}/cards/{cardId}/unassign', 'verb' => 'PUT'], ['name' => 'card_ocs#removeLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card_ocs#reorder', 'url' => '/api/v{apiVersion}/cards/{cardId}/reorder', 'verb' => 'PUT'], diff --git a/lib/Controller/CardOcsController.php b/lib/Controller/CardOcsController.php index c64e21119d..10d4bcdbb0 100644 --- a/lib/Controller/CardOcsController.php +++ b/lib/Controller/CardOcsController.php @@ -92,6 +92,19 @@ public function assignUser(?int $boardId, int $cardId, string $userId, int $type return new DataResponse($this->assignmentService->assignUser($cardId, $userId, $type)); } + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + public function unAssignUser(?int $boardId, int $cardId, string $userId, int $type = 0): DataResponse { + if ($boardId) { + $localBoard = $this->boardService->find($boardId, false); + if ($localBoard->getExternalId()) { + return new DataResponse($this->externalBoardService->unAssignUserOnRemote($localBoard, $cardId, $userId, $type)); + } + } + return new DataResponse($this->assignmentService->unAssignUser($cardId, $userId, $type)); + } + #[NoAdminRequired] #[PublicPage] #[NoCSRFRequired] diff --git a/lib/Service/ExternalBoardService.php b/lib/Service/ExternalBoardService.php index e6ce8478f5..fd48517ec1 100644 --- a/lib/Service/ExternalBoardService.php +++ b/lib/Service/ExternalBoardService.php @@ -7,7 +7,7 @@ namespace OCA\Deck\Service; -use OC\Federation\CloudIdManager; +use Exception; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Assignment; use OCA\Deck\Db\Board; @@ -56,10 +56,10 @@ public function getExternalStacksFromRemote(Board $localBoard):DataResponse { return new DataResponse($this->LocalizeRemoteStacks($ocs, $localBoard)); } - public function localizeRemoteUser(Board $localBoard, array $user): array | User | FederatedUser | null { + public function localizeRemoteUser(Board $localBoard, array $user): array|User|FederatedUser|null { // skip invalid users if (!$user['uid']) { - return null;; + return null; } // if it's already a valid cloud id the user originates from a third instance and we pass it as is if ($this->cloudIdManager->isValidCloudId($user['uid'])) { @@ -75,13 +75,13 @@ public function localizeRemoteUser(Board $localBoard, array $user): array | User if ($owner instanceof FederatedUser) { return new FederatedUser($this->cloudIdManager->getCloudId($user['uid'], $owner->getCloudId()->getRemote())); } - return null; + throw new Exception('Owner of the board must be a federated user to localize remote users'); } public function LocalizeRemoteStacks(array $stacks, Board $localBoard) { foreach ($stacks as $i => $stack) { $stack['boardId'] = $localBoard->getId(); - foreach($stack['cards'] as $j => $card) { + foreach ($stack['cards'] as $j => $card) { $stack['cards'][$j]['assignedUsers'] = array_map(function ($assignment) use ($localBoard) { $assignment['participant'] = $this->localizeRemoteUser($localBoard, $assignment['participant']); return $assignment; @@ -207,24 +207,13 @@ public function assignUserOnRemote(Board $localBoard, int $cardId, string $userI $this->configService->ensureFederationEnabled(); $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + [$resolvedUserId, $resolvedType] = $this->resolveUserIdForRemote($userId, $ownerCloudId); - if ($this->cloudIdManager->isValidCloudId($userId)) { - $cloudId = $this->cloudIdManager->resolveCloudId($userId); - // assignee's origin is the same as the board owner's origin: send as local user - if ($cloudId->getRemote() === $ownerCloudId->getRemote()) { - $userId = $cloudId->getUser(); - $type = Assignment::TYPE_USER; - } - } else { - // local user for us = remote user for remote - $userId = $this->cloudIdManager->getCloudId($userId, null)->getId(); - $type = Assignment::TYPE_REMOTE; - } $shareToken = $localBoard->getShareToken(); $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/assign'; $resp = $this->proxy->post($ownerCloudId->getId(), $shareToken, $url, [ - 'userId' => $userId, - 'type' => $type, + 'userId' => $resolvedUserId, + 'type' => $resolvedType, 'boardId' => $localBoard->getExternalId(), ]); $result = $this->proxy->getOcsData($resp); @@ -234,6 +223,38 @@ public function assignUserOnRemote(Board $localBoard, int $cardId, string $userI return $result; } + public function unAssignUserOnRemote(Board $localBoard, int $cardId, string $userId, int $type = 0): array { + $this->configService->ensureFederationEnabled(); + + $ownerCloudId = $this->cloudIdManager->resolveCloudId($localBoard->getOwner()); + [$resolvedUserId, $resolvedType] = $this->resolveUserIdForRemote($userId, $ownerCloudId); + + $shareToken = $localBoard->getShareToken(); + $url = $ownerCloudId->getRemote() . '/ocs/v2.php/apps/deck/api/v1.0/cards/' . $cardId . '/unassign'; + $resp = $this->proxy->put($ownerCloudId->getId(), $shareToken, $url, [ + 'userId' => $resolvedUserId, + 'type' => $resolvedType, + 'boardId' => $localBoard->getExternalId(), + ]); + return $this->proxy->getOcsData($resp); + } + /** + * Resolves the userId and type before sending it to remote instances + * @param string $userId + * @param $ownerCloudId + * @return array [userId, type] + */ + private function resolveUserIdForRemote(string $userId, $ownerCloudId): array { + if ($this->cloudIdManager->isValidCloudId($userId)) { + $cloudId = $this->cloudIdManager->resolveCloudId($userId); + // assignee's origin is the same as the board owner's origin: send as local user + if ($cloudId->getRemote() === $ownerCloudId->getRemote()) { + return [$cloudId->getUser(), Assignment::TYPE_USER]; + } + } + // local user for us = remote user for remote + return [$this->cloudIdManager->getCloudId($userId, null)->getId(), Assignment::TYPE_REMOTE]; + } public function createStackOnRemote( Board $localBoard, diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 0f4f4c794c..4de5327367 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -29,7 +29,7 @@ class PermissionService { - /** @var array> */ + /** @var array> */ private $users = []; // accessToken to check permission for federated shares @@ -266,7 +266,7 @@ public function getUserId(): ?string { * * @param $boardId * @param $refresh - * @return array + * @return array * */ public function findUsers($boardId, $refresh = false) { // cache users of a board so we don't query them for every cards diff --git a/src/components/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index 2effaba2cc..3d0a3de03e 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -90,7 +90,7 @@ const SOURCE_TO_SHARE_TYPE = { groups: 1, emails: 4, remotes: 6, - teams: 7, + circles: 7, } export default { @@ -139,6 +139,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] } return res }).slice(0, 10) diff --git a/src/services/CardApi.js b/src/services/CardApi.js index c5a4b2309a..59cfbea387 100644 --- a/src/services/CardApi.js +++ b/src/services/CardApi.js @@ -125,11 +125,11 @@ export class CardApi { }) } - removeUser(cardId, id, type) { - return axios.put(this.url(`/cards/${cardId}/unassign`), { userId: id, type }) + removeUser(cardId, id, type, boardId) { + return axios.put(this.ocsUrl(`/cards/${cardId}/unassign`), { userId: id, type, boardId }) .then( (response) => { - return Promise.resolve(response.data) + return Promise.resolve(response.data.ocs.data) }, (err) => { return Promise.reject(err) diff --git a/src/store/card.js b/src/store/card.js index f944da645f..94cadb39a3 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -359,7 +359,8 @@ export default function cardModuleFactory() { commit('assignCardToUser', user) }, async removeUserFromCard({ commit }, { card, assignee }) { - const user = await apiClient.removeUser(card.id, assignee.userId, assignee.type) + const boardId = this.state.currentBoard.id + const user = await apiClient.removeUser(card.id, assignee.userId, assignee.type, boardId) commit('removeUserFromCard', user) }, async addLabel({ commit }, data) { diff --git a/tests/unit/Service/PermissionServiceTest.php b/tests/unit/Service/PermissionServiceTest.php index 05139f5fa7..3099b9edd2 100644 --- a/tests/unit/Service/PermissionServiceTest.php +++ b/tests/unit/Service/PermissionServiceTest.php @@ -32,6 +32,7 @@ use OCA\Deck\Db\User; use OCA\Deck\NoPermissionException; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Federation\ICloudIdManager; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; @@ -53,6 +54,7 @@ class PermissionServiceTest extends \Test\TestCase { private IGroupManager|MockObject $groupManager; private MockObject|IManager $shareManager; private IConfig|MockObject $config; + private ICloudIdManager|MockObject $cloudIdManager; public function setUp(): void { parent::setUp(); @@ -65,6 +67,7 @@ public function setUp(): void { $this->groupManager = $this->createMock(IGroupManager::class); $this->shareManager = $this->createMock(IManager::class); $this->config = $this->createMock(IConfig::class); + $this->cloudIdManager = $this->createMock(ICloudIdManager::class); $this->service = new PermissionService( $this->logger, @@ -76,6 +79,7 @@ public function setUp(): void { $this->groupManager, $this->shareManager, $this->config, + $this->cloudIdManager, 'admin' ); } From 0ddeabe28184420f1307c24c376b24b9dd72a615 Mon Sep 17 00:00:00 2001 From: grnd-alt Date: Tue, 31 Mar 2026 15:45:15 +0200 Subject: [PATCH 3/3] test(federation): add behat tests for assigning users Signed-off-by: grnd-alt --- .../features/bootstrap/FederationContext.php | 70 +++++++++++++++++++ .../features/federation/cards.feature | 13 ++++ 2 files changed, 83 insertions(+) diff --git a/tests/integration/features/bootstrap/FederationContext.php b/tests/integration/features/bootstrap/FederationContext.php index 7a1052dad6..88ba599364 100644 --- a/tests/integration/features/bootstrap/FederationContext.php +++ b/tests/integration/features/bootstrap/FederationContext.php @@ -32,6 +32,8 @@ class FederationContext implements Context { private ?array $board = null; /** @var array|null Last stack created/fetched */ private ?array $stack = null; + /** @var array|null Last card created */ + private ?array $card = null; /** @BeforeScenario */ public function gatherContexts(BeforeScenarioScope $scope) { @@ -242,6 +244,50 @@ public function userOnShouldNotSeeBoard(string $user, string $server, string $bo Assert::assertFalse($found, "Board '{$boardTitle}' should not be visible for user '{$user}' on {$server}"); } + /** + * @Then /^user "([^"]*)" on "([^"]*)" should see assigned user "([^"]*)" on card "([^"]*)" on the federated board "([^"]*)"$/ + */ + public function userOnShouldSeeAssigned(string $user, string $server, string $assignedUser, string $cardTitle, string $boardTitle) { + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/boards', [], $user, $server); + $boards = $this->getOCSData(); + + $found = false; + foreach ($boards as $board) { + if ($board['title'] === $boardTitle) { + $found = $board; + break; + } + } + + Assert::assertNotNull($found, "Board '{$boardTitle}' not found for user '{$user}' on {$server}"); + + $this->sendOCSRequest('GET', '/apps/deck/api/v1.0/stacks/' . $found['id'], [], $user, $server); + $stacks = $this->getOCSData(); + $cardTitleFound = false; + $cardFound = false; + $assignedUsers = []; + foreach ($stacks as $stack) { + foreach ($stack['cards'] as $card) { + if ($card['title'] === $cardTitle) { + $cardTitleFound = true; + foreach ($card['assignedUsers'] as $assigned) { + $assignedUsers[] = $assigned; + if ($assigned['participant']['displayname'] === $assignedUser) { + $cardFound = true; + break 3; + } + } + } + } + } + + Assert::assertTrue($cardTitleFound, "Card '{$cardTitle}' not found on board '{$boardTitle}'"); + + Assert::assertTrue($cardFound, "Assigned user '{$assignedUser}' not found on card '{$cardTitle}' on board '{$boardTitle}' found '" . json_encode($assignedUsers) . "'"); + } + + + /** * @When /^user "([^"]*)" on "([^"]*)" creates a stack "([^"]*)" on the federated board$/ */ @@ -280,6 +326,7 @@ public function userCreatesCardOnFederatedBoard(string $user, string $server, st 'stackId' => $stackId, 'boardId' => $federatedBoard['id'], ], $user, $server); + $this->card = $this->getOCSData(); } /** @@ -342,4 +389,27 @@ private function findFederatedBoard(string $user, string $server): array { throw new \RuntimeException('No federated board "' . $expectedTitle . '" found for user ' . $user . ' on ' . $server); } + + /** + * @When /^user "([^"]*)" on "([^"]*)" assigns user "([^"]*)" to card "([^"]*)" on the federated board$/ + * + * Assign a user to a card by card title on the federated board. + * + * @param string $user The acting user + * @param string $server LOCAL or REMOTE + * @param string $userId The user id to assign + * @param string $cardTitle The card title + */ + public function assignUserToCard(string $user, string $server, string $userId, string $cardTitle) { + $federatedBoard = $this->findFederatedBoard($user, $server); + Assert::assertNotNull($this->card, 'No card created in this scenario'); + Assert::assertEquals($cardTitle, $this->card['title'], 'Last card title does not match'); + $cardId = $this->card['id']; + $data = [ + 'userId' => $userId, + 'type' => 0, + ]; + $this->sendOCSRequest('POST', "/apps/deck/api/v1.0/cards/{$cardId}/assign?boardId={$federatedBoard['id']}", $data, $user, $server); + Assert::assertEquals(200, $this->response->getStatusCode(), "Failed to assign user '{$userId}' to card '{$cardTitle}' on federated board: " . (string)$this->response->getBody()); + } } diff --git a/tests/integration/features/federation/cards.feature b/tests/integration/features/federation/cards.feature index b21510c12a..4f65983ffc 100644 --- a/tests/integration/features/federation/cards.feature +++ b/tests/integration/features/federation/cards.feature @@ -19,3 +19,16 @@ Feature: Federation card operations | permissionEdit | 1 | And user "admin" on "REMOTE" creates a card "Remote Card" on stack "ToDo" on the federated board Then the OCS response should have status code "200" + + Scenario: Assign user on a federated board + Given using server "LOCAL" + And acting as user "admin" + And creates a board named "Assigning Board" with color "ff0000" + And create a stack named "ToDo" + When user "admin" on "LOCAL" shares the board with federated user "admin" + | permissionEdit | 1 | + And user "admin" on "REMOTE" creates a card "Remote Card" on stack "ToDo" on the federated board + And user "admin" on "REMOTE" assigns user "admin" to card "Remote Card" on the federated board + Then user "admin" on "LOCAL" should see assigned user "admin" on card "Remote Card" on the federated board "Assigning Board" + Then user "admin" on "REMOTE" should see assigned user "admin" on card "Remote Card" on the federated board "Assigning Board" +