Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
526b6b5
feat: add owner_type field to boards for circle ownership support
jospoortvliet Apr 29, 2026
e8d455b
feat: wire circle ownership into BoardMapper
jospoortvliet Apr 29, 2026
a107be6
feat: grant owner-level permissions to circle members on circle-owned…
jospoortvliet Apr 29, 2026
71fd2cf
feat: support transferring board ownership to a circle in BoardService
jospoortvliet Apr 29, 2026
0c8b2d9
feat: accept newOwnerType in the transfer-ownership REST endpoint
jospoortvliet Apr 29, 2026
9d1e7ce
feat: add --to-circle flag to deck:transfer-ownership OCC command
jospoortvliet Apr 29, 2026
0f5877d
feat: show team icon for circle-owned boards and add Transfer ownersh…
jospoortvliet Apr 29, 2026
e2d86c2
fix: use Member::TYPE_USER constant instead of magic integer 1
jospoortvliet Apr 29, 2026
9f1864c
fix: scope user-owner queries to owner_type = PERMISSION_TYPE_USER
jospoortvliet Apr 29, 2026
45de51c
perf: cache getUserCircles result per user within a request
jospoortvliet Apr 29, 2026
d3c4680
refactor: centralise ACL permission type constants in src/helpers/con…
jospoortvliet Apr 29, 2026
719461d
refactor: remove redundant owner equality check in transferOwnership
jospoortvliet Apr 29, 2026
ba9603c
chore: drop explicit unsigned=false from migration column definition
jospoortvliet Apr 29, 2026
ae28e40
Fix issue in SharingTabSidebar & add migration back
jospoortvliet Apr 30, 2026
c3867c6
fix: show Transfer ownership button for circle/team ACL entries
jospoortvliet Apr 30, 2026
c790153
add the package lot that was missed earlier, and update .gitignore to…
jospoortvliet May 1, 2026
356172b
feat: support team-aware ownership transfer and notifications
jospoortvliet May 1, 2026
5461e93
* check via canTransferTo if the user is in a circle that has ownership
jospoortvliet May 2, 2026
3baaeb9
ensure a team stays in sharelist when made owner,
jospoortvliet May 2, 2026
a3dcd62
* Ensure we delete boards owned by a circle when the circle gets deleted
jospoortvliet May 2, 2026
55bbcdc
fix some boards not showing up for users when added to a circle after…
jospoortvliet May 4, 2026
81af66a
Fix php-cs-fixer violations in circle ownership feature
jospoortvliet May 4, 2026
3362e48
Fix Psalm errors in circle ownership feature
jospoortvliet May 4, 2026
fae6d6e
Fix PHPUnit 11 and ESLint compatibility in test suite
jospoortvliet May 5, 2026
d4366f2
Fix Cypress CI: read Node/npm version from package.json engines
jospoortvliet May 5, 2026
a00c00a
Fix php-cs: correct indentation of closing ] in cloneBoard()
jospoortvliet May 5, 2026
3ef0a0a
Fix PHPUnit: define Circles stubs when circles app is not installed i…
jospoortvliet May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .github/workflows/cypress-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' ]
Expand All @@ -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)"
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ tests/integration/composer.lock
tests/.phpunit.result.cache
vendor/
.php_cs.cache
.php-cs-fixer.cache
\.idea/
settings.json
runtests.sh
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ 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: 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: 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)
Expand Down
123 changes: 108 additions & 15 deletions lib/Command/TransferOwnership.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
*/
namespace OCA\Deck\Command;

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;
Expand All @@ -22,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() {
Expand All @@ -39,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',
Expand All @@ -57,15 +64,96 @@ protected function configure() {
InputOption::VALUE_NONE,
'Reassign card details of the old owner to the new one'
)
->addOption(
'to-team',
null,
InputOption::VALUE_NONE,
'Treat <newOwner> as a team ID (internally stored as a circle ID) instead of a user UID'
)
->addOption(
'from-team',
null,
InputOption::VALUE_NONE,
'Treat <owner> as a team ID (internally stored as a circle ID) instead of a user UID'
)
;
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$owner = $input->getArgument('owner');
$newOwner = $input->getArgument('newOwner');
$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('<error>Ambiguous source owner: ' . $owner . ' matches both a user and a team (circle ID). Use --from-team if you mean the team.</error>');
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;
}
}
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);
if ($circle !== null) {
$circleExists = true;
$teamDisplayName = $circle->getDisplayName();
} else {
$circleExists = false;
}
} catch (\Throwable $e) {
$circleExists = false;
}
}

if ($userExists && $circleExists) {
$output->writeln('<error>Ambiguous target: ' . $newOwner . ' matches both a user and a team (circle ID). Use --to-team to transfer to the team.</error>');
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('<comment>Detected team target: treating ' . $newOwner . ' as team ' . ($teamDisplayName ?: $newOwner) . '.</comment>');
}
}
$newOwnerLabel = $newOwnerType === Acl::PERMISSION_TYPE_CIRCLE ? 'team ' . ($teamDisplayName ?: $newOwner) : $newOwner;

$this->boardService->setUserId($owner);
$this->permissionService->setUserId($owner);
Expand All @@ -83,26 +171,31 @@ 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);
if (!$this->questionHelper->ask($input, $output, $question)) {
return 1;
}

if ($boardId) {
$this->boardService->transferBoardOwnership($boardId, $newOwner, $remapAssignment);
$output->writeln('<info>Board ' . $board->getTitle() . ' from ' . $board->getOwner() . " transferred to $newOwner completed</info>");
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('<info>Board ' . $board->getTitle() . " transferred to $newOwnerLabel</info>");
return 0;
}

foreach ($this->boardService->transferOwnership($owner, $newOwner, $remapAssignment, $newOwnerType, $ownerType) as $board) {
$output->writeln(' - ' . $board->getTitle() . ' transferred');
}
$output->writeln("<info>All boards from $owner transferred to $newOwnerLabel</info>");
} catch (\Exception $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln("<info>All boards from $owner to $newOwner transferred</info>");

return 0;
}
Expand Down
8 changes: 5 additions & 3 deletions lib/Controller/BoardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
5 changes: 5 additions & 0 deletions lib/Db/Acl.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,6 +43,7 @@ class Acl extends RelationalEntity {
protected $permissionShare = false;
protected $permissionManage = false;
protected $owner = false;
protected $retainsAccessViaMembership = false;
protected $token = null;

public function __construct() {
Expand All @@ -51,8 +54,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');
}

Expand Down
6 changes: 6 additions & 0 deletions lib/Db/Board.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -30,8 +32,11 @@
* @method int | null getExternalId()
*/
class Board extends RelationalEntity {
public const PERMISSION_OWNER = 4;

protected $title;
protected $owner;
protected $ownerType = 0;
protected $color;
protected $archived = false;
/** @var Label[]|null */
Expand All @@ -53,6 +58,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');
Expand Down
Loading
Loading