diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeWorkspaceOwner.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeWorkspaceOwner.php index e5b36e4eae2..740fed703b6 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeWorkspaceOwner.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/ChangeWorkspaceOwner.php @@ -11,7 +11,8 @@ * Change workspace owner of a workspace, identified by $workspaceName. * Setting $newWorkspaceOwner to null, removes the current workspace owner. * - * @api commands are the write-API of the ContentRepository + * @deprecated with 9.0.0-beta14 owners/collaborators should be assigned to workspaces outside the Content Repository core + * @internal */ final readonly class ChangeWorkspaceOwner implements CommandInterface { diff --git a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/RenameWorkspace.php b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/RenameWorkspace.php index 38c91a85f11..edd76b21fa3 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/RenameWorkspace.php +++ b/Neos.ContentRepository.Core/Classes/Feature/WorkspaceModification/Command/RenameWorkspace.php @@ -12,7 +12,8 @@ /** * Change the title or description of a workspace * - * @api commands are the write-API of the ContentRepository + * @deprecated with 9.0.0-beta14 metadata should be assigned to workspaces outside the Content Repository core + * @internal */ final readonly class RenameWorkspace implements CommandInterface { diff --git a/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php b/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php index e2a294126d5..ddb826aff52 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php +++ b/Neos.ContentRepository.Core/Classes/Projection/Workspace/Workspace.php @@ -24,31 +24,73 @@ * * @api */ -class Workspace +final readonly class Workspace { /** * This prefix determines if a given workspace (name) is a user workspace. + * @deprecated with 9.0.0-beta14 metadata should be assigned to workspaces outside the Content Repository core */ public const PERSONAL_WORKSPACE_PREFIX = 'user-'; + /** + * @var WorkspaceName Workspace identifier, unique within one Content Repository instance + */ + public WorkspaceName $workspaceName; + + /** + * @var WorkspaceName|null Workspace identifier of the base workspace (i.e. the target when publishing changes) – if null this instance is considered a root (aka public) workspace + */ + public ?WorkspaceName $baseWorkspaceName; + + /** + * @deprecated with 9.0.0-beta14 metadata should be assigned to workspaces outside the Content Repository core + */ + public WorkspaceTitle $workspaceTitle; + + /** + * @deprecated with 9.0.0-beta14 metadata should be assigned to workspaces outside the Content Repository core + */ + public WorkspaceDescription $workspaceDescription; + + /** + * The Content Stream this workspace currently points to – usually it is set to a new, empty content stream after publishing/rebasing the workspace + */ + public ContentStreamId $currentContentStreamId; + + /** + * The current status of this workspace + */ + public WorkspaceStatus $status; + + /** + * @deprecated with 9.0.0-beta14 owners/collaborators should be assigned to workspaces outside the Content Repository core + */ + public string|null $workspaceOwner; + /** * @internal */ public function __construct( - public readonly WorkspaceName $workspaceName, - public readonly ?WorkspaceName $baseWorkspaceName, - public readonly WorkspaceTitle $workspaceTitle, - public readonly WorkspaceDescription $workspaceDescription, - public readonly ContentStreamId $currentContentStreamId, - public readonly WorkspaceStatus $status, - public readonly ?string $workspaceOwner + WorkspaceName $workspaceName, + ?WorkspaceName $baseWorkspaceName, + WorkspaceTitle $workspaceTitle, + WorkspaceDescription $workspaceDescription, + ContentStreamId $currentContentStreamId, + WorkspaceStatus $status, + ?string $workspaceOwner ) { + $this->workspaceName = $workspaceName; + $this->baseWorkspaceName = $baseWorkspaceName; + $this->workspaceTitle = $workspaceTitle; + $this->workspaceDescription = $workspaceDescription; + $this->currentContentStreamId = $currentContentStreamId; + $this->status = $status; + $this->workspaceOwner = $workspaceOwner; } - /** * Checks if this workspace is a user's personal workspace - * @api + * @deprecated with 9.0.0-beta14 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isPersonalWorkspace(): bool { @@ -59,7 +101,7 @@ public function isPersonalWorkspace(): bool * Checks if this workspace is shared only across users with access to internal workspaces, for example "reviewers" * * @return bool - * @api + * @deprecated with 9.0.0-beta14 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isPrivateWorkspace(): bool { @@ -70,6 +112,7 @@ public function isPrivateWorkspace(): bool * Checks if this workspace is shared across all editors * * @return boolean + * @deprecated with 9.0.0-beta14 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isInternalWorkspace(): bool { @@ -80,6 +123,7 @@ public function isInternalWorkspace(): bool * Checks if this workspace is public to everyone, even without authentication * * @return boolean + * @deprecated with 9.0.0-beta14 owners/collaborators should be assigned to workspaces outside the Content Repository core */ public function isPublicWorkspace(): bool { diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceDescription.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceDescription.php index 8192ab1646c..3181b351960 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceDescription.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceDescription.php @@ -17,7 +17,8 @@ /** * Description for a workspace * - * @api + * @deprecated with 9.0.0-beta14 metadata should be assigned to workspaces outside the Content Repository core + * @internal */ final readonly class WorkspaceDescription implements \JsonSerializable { diff --git a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceTitle.php b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceTitle.php index 4e18d3be20f..e00ee41690e 100644 --- a/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceTitle.php +++ b/Neos.ContentRepository.Core/Classes/SharedModel/Workspace/WorkspaceTitle.php @@ -17,7 +17,8 @@ /** * Human-Readable title of a workspace * - * @api + * @deprecated with 9.0.0-beta14 metadata should be assigned to workspaces outside the Content Repository core + * @internal */ final readonly class WorkspaceTitle implements \JsonSerializable { diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php index fec6f30e5a5..bdfcd1dc5e4 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Features/WorkspaceCreation.php @@ -68,7 +68,7 @@ public function theCommandCreateRootWorkspaceIsExecutedWithPayload(TableNode $pa $command = CreateRootWorkspace::create( WorkspaceName::fromString($commandArguments['workspaceName']), new WorkspaceTitle($commandArguments['workspaceTitle'] ?? ucfirst($commandArguments['workspaceName'])), - new WorkspaceDescription($commandArguments['workspaceDescription'] ?? 'The workspace "' . $commandArguments['workspaceName'] . '"'), + new WorkspaceDescription($commandArguments['workspaceDescription'] ?? ('The workspace "' . $commandArguments['workspaceName'] . '"')), ContentStreamId::fromString($commandArguments['newContentStreamId']) ); diff --git a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php index f52f569ceb2..88614645a23 100644 --- a/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php +++ b/Neos.ContentRepository.TestSuite/Classes/Behavior/Features/Bootstrap/Helpers/FakeUserIdProvider.php @@ -9,7 +9,7 @@ final class FakeUserIdProvider implements UserIdProviderInterface { - private static ?UserId $userId = null; + public static ?UserId $userId = null; public static function setUserId(UserId $userId): void { diff --git a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php index 413290f86b7..c0401b67d1c 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php +++ b/Neos.ContentRepositoryRegistry/Classes/Command/MigrateEventsCommandController.php @@ -1,4 +1,5 @@ contentRepositoryRegistry->buildService($contentRepositoryId, $this->eventMigrationServiceFactory); + $eventMigrationService->migrateWorkspaceMetadataToWorkspaceService($this->outputLine(...)); + } + /** * Migrates "propertyValues":{"tagName":{"value":null,"type":"string"}} to "propertiesToUnset":["tagName"] * diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php index 41ce8c22fe7..32dbba18330 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationService.php @@ -25,9 +25,15 @@ use Neos\ContentRepositoryRegistry\Command\MigrateEventsCommandController; use Neos\ContentRepositoryRegistry\Factory\EventStore\DoctrineEventStoreFactory; use Neos\EventStore\EventStoreInterface; +use Neos\EventStore\Model\Event\EventType; +use Neos\EventStore\Model\Event\EventTypes; use Neos\EventStore\Model\Event\SequenceNumber; use Neos\EventStore\Model\EventEnvelope; +use Neos\EventStore\Model\EventStream\EventStreamFilter; use Neos\EventStore\Model\EventStream\VirtualStreamName; +use Neos\Neos\Domain\Model\WorkspaceClassification; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; /** * Content Repository service to perform migrations of events. @@ -45,7 +51,7 @@ final class EventMigrationService implements ContentRepositoryServiceInterface public function __construct( private readonly ContentRepositoryId $contentRepositoryId, private readonly EventStoreInterface $eventStore, - private readonly Connection $connection, + private readonly Connection $connection ) { } @@ -469,6 +475,179 @@ public function migratePayloadToValidWorkspaceNames(\Closure $outputFn): void $outputFn(sprintf('You need to replay your projection for workspaces. Please run: ./flow cr:projectionreplay --projection=workspace')); } + /** + * Migrates initial metadata & roles from the CR core workspaces to the corresponding Neos database tables + * + * Needed to extract these information to Neos.Neos: https://github.com/neos/neos-development-collection/issues/4726 + * + * Included in September 2024 - before final Neos 9.0 release + * + * @param \Closure $outputFn + * @return void + */ + public function migrateWorkspaceMetadataToWorkspaceService(\Closure $outputFn): void + { + $numberOfHandledWorkspaceEvents = 0; + + $workspaces = []; + $eventTypes = EventTypes::create(...array_map(EventType::fromString(...), ['RootWorkspaceWasCreated', 'WorkspaceWasCreated', 'WorkspaceBaseWorkspaceWasChanged', 'WorkspaceOwnerWasChanged', 'WorkspaceWasRenamed', 'WorkspaceWasRemoved'])); + // building up the state. Mimic how the legacy WorkspaceProjection handles the events and builds up the state. + foreach ($this->eventStore->load(VirtualStreamName::all(), EventStreamFilter::create(eventTypes: $eventTypes)) as $eventEnvelope) { + $eventType = $eventEnvelope->event->type->value; + $numberOfHandledWorkspaceEvents++; + + switch ($eventType) { + case 'RootWorkspaceWasCreated': + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($eventData['workspaceTitle'])) { + // without the field it's not a legacy workspace creation event + continue 2; + } + $workspaces[$eventData['workspaceName']] = [ + 'workspaceName' => $eventData['workspaceName'], + 'workspaceTitle' => $eventData['workspaceTitle'], + 'workspaceDescription' => $eventData['workspaceDescription'], + 'baseWorkspaceName' => null, + 'workspaceOwner' => null + ]; + break; + case 'WorkspaceWasCreated': + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($eventData['workspaceTitle'])) { + // without the field it's not a legacy workspace creation event + continue 2; + } + $workspaces[$eventData['workspaceName']] = [ + 'workspaceName' => $eventData['workspaceName'], + 'baseWorkspaceName' => $eventData['baseWorkspaceName'], + 'workspaceTitle' => $eventData['workspaceTitle'], + 'workspaceDescription' => $eventData['workspaceDescription'], + 'workspaceOwner' => $eventData['workspaceOwner'] ?? null + ]; + break; + case 'WorkspaceBaseWorkspaceWasChanged': + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($workspaces[$eventData['workspaceName']])) { + continue 2; + } + $workspaces[$eventData['workspaceName']] = [ + 'baseWorkspaceName' => $eventData['baseWorkspaceName'], + ...$workspaces[$eventData['workspaceName']] + ]; + break; + case 'WorkspaceOwnerWasChanged': + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($workspaces[$eventData['workspaceName']])) { + continue 2; + } + $workspaces[$eventData['workspaceName']] = [ + 'workspaceOwner' => $eventData['newWorkspaceOwner'], + ...$workspaces[$eventData['workspaceName']] + ]; + break; + case 'WorkspaceWasRenamed': + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($workspaces[$eventData['workspaceName']])) { + continue 2; + } + $workspaces[$eventData['workspaceName']] = [ + 'workspaceTitle' => $eventData['workspaceTitle'], + 'workspaceDescription' => $eventData['workspaceDescription'], + ...$workspaces[$eventData['workspaceName']] + ]; + break; + case 'WorkspaceWasRemoved': + $eventData = self::decodeEventPayload($eventEnvelope); + if (!isset($workspaces[$eventData['workspaceName']])) { + continue 2; + } + unset($workspaces[$eventData['workspaceName']]); + break; + default: + throw new \Exception('Unhandled event type: ' . $eventType); + } + } + if (count($workspaces) === 0) { + $outputFn('Migration was not necessary.'); + return; + } + + $outputFn(sprintf('Found %d legacy workspace events resulting in %d workspaces.', $numberOfHandledWorkspaceEvents, count($workspaces))); + + // adding metadata + $addedWorkspaceMetadata = 0; + foreach ($workspaces as $workspaceRow) { + $workspaceName = WorkspaceName::fromString($workspaceRow['workspaceName']); + $baseWorkspaceName = isset($workspaceRow['baseWorkspaceName']) ? WorkspaceName::fromString($workspaceRow['baseWorkspaceName']) : null; + $workspaceOwner = $workspaceRow['workspaceOwner'] ?? null; + $isPersonalWorkspace = str_starts_with($workspaceName->value, 'user-'); + $isPrivateWorkspace = $workspaceOwner !== null && !$isPersonalWorkspace; + $isInternalWorkspace = $baseWorkspaceName !== null && $workspaceOwner === null; + + $query = <<connection->fetchOne($query, [ + 'contentRepositoryId' => $this->contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + if ($metadataExists !== 0) { + $outputFn(sprintf('Metadata for "%s" exists already.', $workspaceName->value)); + continue; + } + if ($baseWorkspaceName === null) { + $classification = WorkspaceClassification::ROOT; + } elseif ($isPersonalWorkspace) { + $classification = WorkspaceClassification::PERSONAL; + } else { + $classification = WorkspaceClassification::SHARED; + } + $this->connection->insert('neos_neos_workspace_metadata', [ + 'content_repository_id' => $this->contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $workspaceRow['workspaceTitle'], + 'description' => $workspaceRow['workspaceDescription'], + 'classification' => $classification->value, + 'owner_user_id' => $isPersonalWorkspace ? $workspaceOwner : null, + ]); + if ($workspaceName->isLive()) { + $this->connection->insert('neos_neos_workspace_role', [ + 'content_repository_id' => $this->contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, + 'subject' => 'Neos.Neos:LivePublisher', + 'role' => WorkspaceRole::COLLABORATOR->value, + ]); + } elseif ($isInternalWorkspace) { + $this->connection->insert('neos_neos_workspace_role', [ + 'content_repository_id' => $this->contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => WorkspaceRoleSubjectType::GROUP->value, + 'subject' => 'Neos.Neos:AbstractEditor', + 'role' => WorkspaceRole::COLLABORATOR->value, + ]); + } elseif ($isPrivateWorkspace) { + $this->connection->insert('neos_neos_workspace_role', [ + 'content_repository_id' => $this->contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => WorkspaceRoleSubjectType::USER->value, + 'subject' => $workspaceOwner, + 'role' => WorkspaceRole::COLLABORATOR->value, + ]); + } + $outputFn(sprintf('Added metadata for "%s".', $workspaceName->value)); + $addedWorkspaceMetadata++; + } + if ($addedWorkspaceMetadata === 0) { + $outputFn('Migration was not necessary.'); + return; + } + + $outputFn(sprintf('Added metadata for %d workspaces.', $addedWorkspaceMetadata)); + } + /** ------------------------ */ /** diff --git a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationServiceFactory.php b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationServiceFactory.php index 6067de83d42..f61935dccaa 100644 --- a/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationServiceFactory.php +++ b/Neos.ContentRepositoryRegistry/Classes/Service/EventMigrationServiceFactory.php @@ -21,7 +21,7 @@ final class EventMigrationServiceFactory implements ContentRepositoryServiceFactoryInterface { public function __construct( - private readonly Connection $connection, + private readonly Connection $connection ) { } diff --git a/Neos.Media.Browser/Classes/Controller/UsageController.php b/Neos.Media.Browser/Classes/Controller/UsageController.php index 89b826814c6..5861c9e77c2 100644 --- a/Neos.Media.Browser/Classes/Controller/UsageController.php +++ b/Neos.Media.Browser/Classes/Controller/UsageController.php @@ -23,6 +23,7 @@ use Neos\Media\Domain\Service\AssetService; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; +use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\Service\UserService; use Neos\Neos\Domain\Service\UserService as DomainUserService; @@ -60,6 +61,12 @@ class UsageController extends ActionController */ protected $contentRepositoryRegistry; + /** + * @Flow\Inject + * @var WorkspaceService + */ + protected $workspaceService; + /** * @Flow\Inject * @var DomainUserService @@ -76,8 +83,9 @@ public function relatedNodesAction(AssetInterface $asset) { $currentContentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $currentContentRepository = $this->contentRepositoryRegistry->get($currentContentRepositoryId); - $userWorkspaceName = WorkspaceName::fromString($this->userService->getPersonalWorkspaceName()); - $userWorkspace = $currentContentRepository->getWorkspaceFinder()->findOneByName($userWorkspaceName); + $userId = $this->userService->getBackendUser()?->getId(); + assert($userId !== null); + $userWorkspace = $this->workspaceService->getPersonalWorkspaceForUser($currentContentRepositoryId, $userId); $usageReferences = $this->assetService->getUsageReferences($asset); $relatedNodes = []; diff --git a/Neos.Neos/Classes/Command/WorkspaceCommandController.php b/Neos.Neos/Classes/Command/WorkspaceCommandController.php index 664bbc87914..b55b5833658 100644 --- a/Neos.Neos/Classes/Command/WorkspaceCommandController.php +++ b/Neos.Neos/Classes/Command/WorkspaceCommandController.php @@ -14,31 +14,30 @@ namespace Neos\Neos\Command; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateRootWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\BaseWorkspaceDoesNotExist; +use Doctrine\DBAL\Exception as DbalException; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; -use Neos\ContentRepository\Core\Projection\Workspace\WorkspaceStatus; use Neos\ContentRepository\Core\Service\WorkspaceMaintenanceServiceFactory; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Exception\WorkspaceDoesNotExist; -use Neos\ContentRepository\Core\SharedModel\User\UserId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Cli\CommandController; use Neos\Flow\Cli\Exception\StopCommandException; -use Neos\Flow\Persistence\PersistenceManagerInterface; +use Neos\Neos\Domain\Model\WorkspaceClassification; +use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; +use Neos\Neos\Domain\Model\WorkspaceRoleSubjectType; +use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Workspace\WorkspaceProvider; -use Neos\Neos\PendingChangesProjection\ChangeFinder; +use Neos\Neos\Domain\Service\WorkspacePublishingService; +use Neos\Neos\Domain\Service\WorkspaceService; /** * The Workspace Command Controller @@ -50,13 +49,13 @@ class WorkspaceCommandController extends CommandController protected UserService $userService; #[Flow\Inject] - protected PersistenceManagerInterface $persistenceManager; + protected ContentRepositoryRegistry $contentRepositoryRegistry; #[Flow\Inject] - protected ContentRepositoryRegistry $contentRepositoryRegistry; + protected WorkspacePublishingService $workspacePublishingService; #[Flow\Inject] - protected WorkspaceProvider $workspaceProvider; + protected WorkspaceService $workspaceService; /** * Publish changes of a workspace @@ -68,16 +67,14 @@ class WorkspaceCommandController extends CommandController */ public function publishCommand(string $workspace, string $contentRepository = 'default'): void { - // @todo: bypass access control - $workspace = $this->workspaceProvider->provideForWorkspaceName( + $this->workspacePublishingService->publishWorkspace( ContentRepositoryId::fromString($contentRepository), WorkspaceName::fromString($workspace) ); - $workspace->publishAllChanges(); $this->outputLine( - 'Published all nodes in workspace %s to its base workspace', - [$workspace->name->value] + 'Published all nodes in workspace "%s" to its base workspace', + [$workspace] ); } @@ -92,19 +89,16 @@ public function publishCommand(string $workspace, string $contentRepository = 'd */ public function discardCommand(string $workspace, string $contentRepository = 'default'): void { - // @todo: bypass access control - $workspace = $this->workspaceProvider->provideForWorkspaceName( - ContentRepositoryId::fromString($contentRepository), - WorkspaceName::fromString($workspace) - ); - try { - $workspace->discardAllChanges(); - } catch (WorkspaceDoesNotExist $exception) { - $this->outputLine('Workspace "%s" does not exist', [$workspace->name->value]); + $this->workspacePublishingService->discardAllWorkspaceChanges( + ContentRepositoryId::fromString($contentRepository), + WorkspaceName::fromString($workspace) + ); + } catch (WorkspaceDoesNotExist) { + $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); } - $this->outputLine('Discarded all nodes in workspace %s', [$workspace->name->value]); + $this->outputLine('Discarded all nodes in workspace "%s"', [$workspace]); } /** @@ -120,107 +114,235 @@ public function discardCommand(string $workspace, string $contentRepository = 'd public function rebaseCommand(string $workspace, string $contentRepository = 'default', bool $force = false): void { try { - // @todo: bypass access control - $workspace = $this->workspaceProvider->provideForWorkspaceName( + $this->workspacePublishingService->rebaseWorkspace( ContentRepositoryId::fromString($contentRepository), - WorkspaceName::fromString($workspace) + WorkspaceName::fromString($workspace), + $force ? RebaseErrorHandlingStrategy::STRATEGY_FORCE : RebaseErrorHandlingStrategy::STRATEGY_FAIL, ); - $workspace->rebase($force ? RebaseErrorHandlingStrategy::STRATEGY_FORCE : RebaseErrorHandlingStrategy::STRATEGY_FAIL); } catch (WorkspaceDoesNotExist $exception) { - $this->outputLine('Workspace "%s" does not exist', [$workspace]); + $this->outputLine('Workspace "%s" does not exist', [$workspace]); $this->quit(1); } catch (WorkspaceRebaseFailed $exception) { - $this->outputLine('Rebasing of workspace %s is not possible due to conflicts. You can try the --force option.', [$workspace]); + $this->outputLine('Rebasing of workspace "%s" is not possible due to conflicts. You can try the --force option.', [$workspace]); $this->quit(1); } - $this->outputLine('Rebased workspace %s', [$workspace->name->value]); + $this->outputLine('Rebased workspace "%s"', [$workspace]); } /** - * Create a new root workspace for a content repository. + * Create a new root workspace for a content repository + * + * NOTE: By default, only administrators can access workspaces without role assignments. Use workspace:assignrole to add workspace permissions * * @param string $name Name of the new root * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string|null $title Optional title of the workspace + * @param string|null $description Optional description of the workspace + * @throws WorkspaceAlreadyExists */ - public function createRootCommand(string $name, string $contentRepository = 'default'): void + public function createRootCommand(string $name, string $contentRepository = 'default', string $title = null, string $description = null): void { + $workspaceName = WorkspaceName::fromString($name); $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->workspaceService->createRootWorkspace( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($title ?? $name), + WorkspaceDescription::fromString($description ?? '') + ); + $this->outputLine('Created root workspace "%s" in content repository "%s"', [$workspaceName->value, $contentRepositoryId->value]); + } - $contentRepositoryInstance->handle(CreateRootWorkspace::create( - WorkspaceName::fromString($name), - WorkspaceTitle::fromString($name), - WorkspaceDescription::fromString($name), - ContentStreamId::create() - )); + /** + * Create a new personal workspace for the specified user + * + * @param string $workspace Name of the workspace, for example "christmas-campaign" + * @param string $owner The username (aka account identifier) of a User to own the workspace + * @param string $baseWorkspace Name of the base workspace. If none is specified, "live" is assumed. + * @param string|null $title Human friendly title of the workspace, for example "Christmas Campaign" + * @param string|null $description A description explaining the purpose of the new workspace + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function createPersonalCommand(string $workspace, string $owner, string $baseWorkspace = 'live', string $title = null, string $description = null, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceOwner = $this->userService->getUser($owner); + if ($workspaceOwner === null) { + $this->outputLine('The user "%s" specified as owner does not exist', [$owner]); + $this->quit(1); + } + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->createPersonalWorkspace( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($title ?? $workspaceName->value), + WorkspaceDescription::fromString($description ?? ''), + WorkspaceName::fromString($baseWorkspace), + $workspaceOwner->getId(), + ); + $this->outputLine('Created personal workspace "%s" for user "%s"', [$workspaceName->value, (string)$workspaceOwner->getName()]); } /** - * Create a new workspace + * Create a new shared workspace * - * This command creates a new workspace. + * NOTE: By default, only administrators can access workspaces without role assignments. Use workspace:assignrole to add workspace permissions * * @param string $workspace Name of the workspace, for example "christmas-campaign" * @param string $baseWorkspace Name of the base workspace. If none is specified, "live" is assumed. * @param string|null $title Human friendly title of the workspace, for example "Christmas Campaign" * @param string|null $description A description explaining the purpose of the new workspace - * @param string $owner The identifier of a User to own the workspace * @param string $contentRepository Identifier of the content repository. (Default: 'default') * @throws StopCommandException */ - public function createCommand( - string $workspace, - string $baseWorkspace = 'live', - string $title = null, - string $description = null, - string $owner = '', - string $contentRepository = 'default' - ): void { + public function createSharedCommand(string $workspace, string $baseWorkspace = 'live', string $title = null, string $description = null, string $contentRepository = 'default'): void + { $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); - $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->createSharedWorkspace( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($title ?? $workspaceName->value), + WorkspaceDescription::fromString($description ?? ''), + WorkspaceName::fromString($baseWorkspace), + ); + $this->outputLine('Created shared workspace "%s"', [$workspaceName->value]); + } - if ($owner === '') { - $workspaceOwnerUserId = null; - } else { - $workspaceOwnerUserId = UserId::fromString($owner); - $workspaceOwner = $this->userService->findByUserIdentifier($workspaceOwnerUserId); - if ($workspaceOwner === null) { - $this->outputLine('The user "%s" specified as owner does not exist', [$owner]); - $this->quit(3); - } - } + /** + * Set/change the title of a workspace + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $newTitle Human friendly title of the workspace, for example "Some workspace" + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function setTitleCommand(string $workspace, string $newTitle, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->setWorkspaceTitle( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($newTitle), + ); + $this->outputLine('Set title of workspace "%s" to "%s"', [$workspaceName->value, $newTitle]); + } - try { - $contentRepositoryInstance->handle(CreateWorkspace::create( - WorkspaceName::fromString($workspace), - WorkspaceName::fromString($baseWorkspace), - WorkspaceTitle::fromString($title ?: $workspace), - WorkspaceDescription::fromString($description ?: $workspace), - ContentStreamId::create(), - $workspaceOwnerUserId - )); - } catch (WorkspaceAlreadyExists $workspaceAlreadyExists) { - $this->outputLine('Workspace "%s" already exists', [$workspace]); - $this->quit(1); - } catch (BaseWorkspaceDoesNotExist $baseWorkspaceDoesNotExist) { - $this->outputLine('The base workspace "%s" does not exist', [$baseWorkspace]); - $this->quit(2); - } + /** + * Set/change the description of a workspace + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $newDescription Human friendly description of the workspace + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function setDescriptionCommand(string $workspace, string $newDescription, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + $this->workspaceService->setWorkspaceDescription( + $contentRepositoryId, + $workspaceName, + WorkspaceDescription::fromString($newDescription), + ); + $this->outputLine('Set description of workspace "%s"', [$workspaceName->value]); + } - if ($workspaceOwnerUserId instanceof UserId) { - $this->outputLine( - 'Created a new workspace "%s", based on workspace "%s", owned by "%s".', - [$workspace, $baseWorkspace, $owner] - ); + /** + * Assign a workspace role to the given user/user group + * + * Without explicit workspace roles, only administrators can change the corresponding workspace. + * With this command, a user or group (represented by a Flow role identifier) can be granted one of the two roles: + * - collaborator: Can read from and write to the workspace + * - manager: Can read from and write to the workspace and manage it (i.e. change metadata & role assignments) + * + * Examples: + * + * To grant editors read and write access to a (shared) workspace: ./flow workspace:assignrole some-workspace "Neos.Neos:AbstractEditor" collaborator + * + * To grant a specific user read, write and manage access to a workspace: ./flow workspace:assignrole some-workspace admin manager --type user + * + * {@see WorkspaceRole} + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $subject The user/group that should be assigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user + * @param string $role Role to assign, either 'collaborator' or 'manager' – a collaborator can read and write from/to the workspace. A manager can _on top_ change the workspace metadata & roles itself + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user + * @throws StopCommandException + */ + public function assignRoleCommand(string $workspace, string $subject, string $role, string $contentRepository = 'default', string $type = 'group'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + + $subjectType = match ($type) { + 'group' => WorkspaceRoleSubjectType::GROUP, + 'user' => WorkspaceRoleSubjectType::USER, + default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), + }; + $workspaceRole = match ($role) { + 'collaborator' => WorkspaceRole::COLLABORATOR, + 'manager' => WorkspaceRole::MANAGER, + default => throw new \InvalidArgumentException(sprintf('role must be "collaborator" or "manager", given "%s"', $role), 1728398880), + }; + if ($subjectType === WorkspaceRoleSubjectType::USER) { + $neosUser = $this->userService->getUser($subject); + if ($neosUser === null) { + $this->outputLine('The user "%s" specified as subject does not exist', [$subject]); + $this->quit(1); + } + $roleSubject = WorkspaceRoleSubject::fromString($neosUser->getId()->value); } else { - $this->outputLine( - 'Created a new workspace "%s", based on workspace "%s".', - [$workspace, $baseWorkspace] - ); + $roleSubject = WorkspaceRoleSubject::fromString($subject); } + $this->workspaceService->assignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleAssignment::create( + $subjectType, + $roleSubject, + $workspaceRole + ) + ); + $this->outputLine('Assigned role "%s" to subject "%s" for workspace "%s"', [$workspaceRole->value, $roleSubject->value, $workspaceName->value]); } + /** + * Unassign a workspace role from the given user/user group + * + * @see assignRoleCommand() + * + * @param string $workspace Name of the workspace, for example "some-workspace" + * @param string $subject The user/group that should be unassigned. By default, this is expected to be a Flow role identifier (e.g. 'Neos.Neos:AbstractEditor') – if $type is 'user', this is the username (aka account identifier) of a Neos user + * @param string $contentRepository Identifier of the content repository. (Default: 'default') + * @param string $type Type of role, either 'group' (default) or 'user' – if 'group', $subject is expected to be a Flow role identifier, otherwise the username (aka account identifier) of a Neos user + * @throws StopCommandException + */ + public function unassignRoleCommand(string $workspace, string $subject, string $contentRepository = 'default', string $type = 'group'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $workspaceName = WorkspaceName::fromString($workspace); + + $subjectType = match ($type) { + 'group' => WorkspaceRoleSubjectType::GROUP, + 'user' => WorkspaceRoleSubjectType::USER, + default => throw new \InvalidArgumentException(sprintf('type must be "group" or "user", given "%s"', $type), 1728398802), + }; + $roleSubject = WorkspaceRoleSubject::fromString($subject); + $this->workspaceService->unassignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + $subjectType, + $roleSubject, + ); + $this->outputLine('Removed role assignment from subject "%s" for workspace "%s"', [$roleSubject->value, $workspaceName->value]); + } + + /** * Deletes a workspace * @@ -230,7 +352,7 @@ public function createCommand( * @param string $workspace Name of the workspace, for example "christmas-campaign" * @param boolean $force Delete the workspace and all of its contents * @param string $contentRepository The name of the content repository. (Default: 'default') - * @see neos.neos:workspace:discard + * @throws StopCommandException */ public function deleteCommand(string $workspace, bool $force = false, string $contentRepository = 'default'): void { @@ -243,49 +365,48 @@ public function deleteCommand(string $workspace, bool $force = false, string $co $this->quit(2); } - $workspace = $contentRepositoryInstance->getWorkspaceFinder()->findOneByName($workspaceName); - if (!$workspace instanceof Workspace) { + $crWorkspace = $contentRepositoryInstance->getWorkspaceFinder()->findOneByName($workspaceName); + if ($crWorkspace === null) { $this->outputLine('Workspace "%s" does not exist', [$workspaceName->value]); $this->quit(1); } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); - if ($workspace->isPersonalWorkspace()) { - $this->outputLine( - 'Did not delete workspace "%s" because it is a personal workspace.' - . ' Personal workspaces cannot be deleted manually.', - [$workspaceName->value] - ); + if ($workspaceMetadata->classification === WorkspaceClassification::PERSONAL) { + $this->outputLine('Did not delete workspace "%s" because it is a personal workspace. Personal workspaces cannot be deleted manually.', [$workspaceName->value]); $this->quit(2); } - $dependentWorkspaces = $contentRepositoryInstance->getWorkspaceFinder()->findByBaseWorkspace($workspaceName); + try { + $dependentWorkspaces = $contentRepositoryInstance->getWorkspaceFinder()->findByBaseWorkspace($workspaceName); + } catch (DbalException $e) { + $this->outputLine('Failed to determine dependant workspaces: %s', [$e->getMessage()]); + $this->quit(1); + } if (count($dependentWorkspaces) > 0) { - $this->outputLine( - 'Workspace "%s" cannot be deleted because the following workspaces are based on it:', - [$workspaceName->value] - ); + $this->outputLine('Workspace "%s" cannot be deleted because the following workspaces are based on it:', [$workspaceName->value]); + $this->outputLine(); $tableRows = []; $headerRow = ['Name', 'Title', 'Description']; foreach ($dependentWorkspaces as $dependentWorkspace) { + $dependentWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $dependentWorkspace->workspaceName); $tableRows[] = [ $dependentWorkspace->workspaceName->value, - $dependentWorkspace->workspaceTitle->value, - $dependentWorkspace->workspaceDescription->value + $dependentWorkspaceMetadata->title->value, + $dependentWorkspaceMetadata->description->value ]; } $this->output->outputTable($tableRows, $headerRow); $this->quit(3); } + try { - $nodesCount = $contentRepositoryInstance->projectionState(ChangeFinder::class) - ->countByContentStreamId( - $workspace->currentContentStreamId - ); + $nodesCount = $this->workspacePublishingService->countPendingWorkspaceChanges($contentRepositoryId, $workspaceName); } catch (\Exception $exception) { - $this->outputLine('Could not fetch unpublished nodes for workspace %s, nothing was deleted. %s', [$workspace->workspaceName->value, $exception->getMessage()]); + $this->outputLine('Could not fetch unpublished nodes for workspace %s, nothing was deleted. %s', [$workspaceName->value, $exception->getMessage()]); $this->quit(4); } @@ -298,12 +419,7 @@ public function deleteCommand(string $workspace, bool $force = false, string $co ); $this->quit(5); } - // @todo bypass access control? - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $contentRepositoryId, - $workspaceName - ); - $workspace->discardAllChanges(); + $this->workspacePublishingService->discardAllWorkspaceChanges($contentRepositoryId, $workspaceName); } $contentRepositoryInstance->handle( @@ -359,20 +475,69 @@ public function listCommand(string $contentRepository = 'default'): void } $tableRows = []; - $headerRow = ['Name', 'Base Workspace', 'Title', 'Owner', 'Description', 'Status', 'Content Stream']; + $headerRow = ['Name', 'Classification', 'Base Workspace', 'Title', 'Description', 'Status', 'Content Stream']; foreach ($workspaces as $workspace) { + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + /* @var Workspace $workspace */ $tableRows[] = [ $workspace->workspaceName->value, - $workspace->baseWorkspaceName?->value ?: '', - $workspace->workspaceTitle->value, - $workspace->workspaceOwner ?: '', - $workspace->workspaceDescription->value, + $workspaceMetadata->classification->value, + $workspace->baseWorkspaceName?->value ?: '-', + $workspaceMetadata->title->value, + $workspaceMetadata->description->value, $workspace->status->value, $workspace->currentContentStreamId->value, ]; } $this->output->outputTable($tableRows, $headerRow); } + + /** + * Display details for the specified workspace + * + * @param string $workspace Name of the workspace to show + * @param string $contentRepository The name of the content repository. (Default: 'default') + * @throws StopCommandException + */ + public function showCommand(string $workspace, string $contentRepository = 'default'): void + { + $contentRepositoryId = ContentRepositoryId::fromString($contentRepository); + $contentRepositoryInstance = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $workspaceName = WorkspaceName::fromString($workspace); + $workspacesInstance = $contentRepositoryInstance->getWorkspaceFinder()->findOneByName($workspaceName); + + if ($workspacesInstance === null) { + $this->outputLine('Workspace "%s" not found.', [$workspaceName->value]); + $this->quit(); + } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspaceName); + + $this->outputFormatted('Name: %s', [$workspacesInstance->workspaceName->value]); + $this->outputFormatted('Classification: %s', [$workspaceMetadata->classification->value]); + $this->outputFormatted('Base Workspace: %s', [$workspacesInstance->baseWorkspaceName?->value ?: '-']); + $this->outputFormatted('Title: %s', [$workspaceMetadata->title->value]); + $this->outputFormatted('Description: %s', [$workspaceMetadata->description->value]); + $this->outputFormatted('Status: %s', [$workspacesInstance->status->value]); + $this->outputFormatted('Content Stream: %s', [$workspacesInstance->currentContentStreamId->value]); + + $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); + $this->outputLine(); + $this->outputLine('Role assignments:'); + if ($workspaceRoleAssignments->isEmpty()) { + $this->outputLine('There are no role assignments for workspace "%s". Use the workspace:assignrole command to assign roles', [$workspaceName->value]); + return; + } + $this->output->outputTable(array_map(static fn (WorkspaceRoleAssignment $assignment) => [ + $assignment->subjectType->value, + $assignment->subject->value, + $assignment->role->value, + ], iterator_to_array($workspaceRoleAssignments)), [ + 'Subject type', + 'Subject', + 'Role', + ]); + } } diff --git a/Neos.Neos/Classes/Domain/Workspace/DiscardingResult.php b/Neos.Neos/Classes/Domain/Model/DiscardingResult.php similarity index 94% rename from Neos.Neos/Classes/Domain/Workspace/DiscardingResult.php rename to Neos.Neos/Classes/Domain/Model/DiscardingResult.php index b6d448206d9..af1fb4d09c1 100644 --- a/Neos.Neos/Classes/Domain/Workspace/DiscardingResult.php +++ b/Neos.Neos/Classes/Domain/Model/DiscardingResult.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\Neos\Domain\Workspace; +namespace Neos\Neos\Domain\Model; use Neos\Flow\Annotations as Flow; diff --git a/Neos.Neos/Classes/Domain/Workspace/PublishingResult.php b/Neos.Neos/Classes/Domain/Model/PublishingResult.php similarity index 79% rename from Neos.Neos/Classes/Domain/Workspace/PublishingResult.php rename to Neos.Neos/Classes/Domain/Model/PublishingResult.php index 8e7215216da..256f5757c1a 100644 --- a/Neos.Neos/Classes/Domain/Workspace/PublishingResult.php +++ b/Neos.Neos/Classes/Domain/Model/PublishingResult.php @@ -12,8 +12,9 @@ declare(strict_types=1); -namespace Neos\Neos\Domain\Workspace; +namespace Neos\Neos\Domain\Model; +use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** @@ -26,6 +27,7 @@ { public function __construct( public int $numberOfPublishedChanges, + public WorkspaceName $targetWorkspaceName, ) { } } diff --git a/Neos.Neos/Classes/Domain/Model/User.php b/Neos.Neos/Classes/Domain/Model/User.php index c7954eb5b90..3dddfa3b11b 100644 --- a/Neos.Neos/Classes/Domain/Model/User.php +++ b/Neos.Neos/Classes/Domain/Model/User.php @@ -36,6 +36,12 @@ class User extends Person implements UserInterface */ protected $preferences; + /** + * This property will be introduced and initialised via Flows persistence magic aspect. + * @var string + */ + protected $Persistence_Object_Identifier; + /** * Constructs this User object */ @@ -45,6 +51,11 @@ public function __construct() $this->preferences = new UserPreferences(); } + public function getId(): UserId + { + return UserId::fromString($this->Persistence_Object_Identifier); + } + /** * Returns a label which can be used as a human-friendly identifier for this user. * diff --git a/Neos.Neos/Classes/Domain/Model/UserId.php b/Neos.Neos/Classes/Domain/Model/UserId.php new file mode 100644 index 00000000000..2011ebb9a56 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/UserId.php @@ -0,0 +1,36 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/UserInterface.php b/Neos.Neos/Classes/Domain/Model/UserInterface.php index bc451ca8936..bc44bc7b951 100644 --- a/Neos.Neos/Classes/Domain/Model/UserInterface.php +++ b/Neos.Neos/Classes/Domain/Model/UserInterface.php @@ -15,9 +15,8 @@ namespace Neos\Neos\Domain\Model; /** - * Interface for a user of the content repository. Users can be owners of workspaces. - * - * @api + * @deprecated with 9.0.0-beta14 please use {@see \Neos\Neos\Domain\Model\User} instead. + * The interface was only needed for the old cr: https://github.com/neos/neos-development-collection/pull/165#issuecomment-157645872 */ interface UserInterface { diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php new file mode 100644 index 00000000000..63ee82f0d38 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceClassification.php @@ -0,0 +1,37 @@ +value) !== 1) { + throw new \InvalidArgumentException('Invalid workspace description given.', 1505831660363); + } + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public static function empty(): self + { + return new self(''); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php new file mode 100644 index 00000000000..582b96f04f2 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceMetadata.php @@ -0,0 +1,28 @@ +classification === WorkspaceClassification::PERSONAL && $this->ownerUserId === null) { + throw new \InvalidArgumentException('The owner-user-id must be set if the workspace is personal.', 1728476633); + } + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php new file mode 100644 index 00000000000..faf543259cf --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspacePermissions.php @@ -0,0 +1,50 @@ +specificity() >= $role->specificity(); + } + + private function specificity(): int + { + return match ($this) { + self::COLLABORATOR => 1, + self::MANAGER => 2, + }; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php new file mode 100644 index 00000000000..fd7d5a7896f --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignment.php @@ -0,0 +1,49 @@ +value), + $role + ); + } + + public static function createForGroup(string $flowRoleIdentifier, WorkspaceRole $role): self + { + return new self( + WorkspaceRoleSubjectType::GROUP, + WorkspaceRoleSubject::fromString($flowRoleIdentifier), + $role + ); + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php new file mode 100644 index 00000000000..82dc1eb4a3f --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php @@ -0,0 +1,51 @@ + + * @api + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceRoleAssignments implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $assignments; + + private function __construct(WorkspaceRoleAssignment ...$assignments) + { + $this->assignments = $assignments; + } + + /** + * @param array $assignments + */ + public static function fromArray(array $assignments): self + { + return new self(...$assignments); + } + + public function isEmpty(): bool + { + return $this->assignments === []; + } + + public function getIterator(): Traversable + { + yield from $this->assignments; + } + + public function count(): int + { + return count($this->assignments); + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php new file mode 100644 index 00000000000..fb80329b09d --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubject.php @@ -0,0 +1,39 @@ +value) !== 1) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid workspace role subject.', $value), 1728384932); + } + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php new file mode 100644 index 00000000000..f4b3eb71e0a --- /dev/null +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleSubjectType.php @@ -0,0 +1,18 @@ +value) !== 1) { + throw new \InvalidArgumentException('Invalid workspace title given.', 1505827170288); + } + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function jsonSerialize(): string + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} diff --git a/Neos.Neos/Classes/Domain/Service/UserService.php b/Neos.Neos/Classes/Domain/Service/UserService.php index bab470d18b0..8ad74db35ec 100644 --- a/Neos.Neos/Classes/Domain/Service/UserService.php +++ b/Neos.Neos/Classes/Domain/Service/UserService.php @@ -15,7 +15,6 @@ namespace Neos\Neos\Domain\Service; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\Flow\Annotations as Flow; use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\PersistenceManagerInterface; @@ -40,6 +39,7 @@ use Neos\Flow\Utility\Now; use Neos\Neos\Domain\Exception; use Neos\Neos\Domain\Model\User; +use Neos\Neos\Domain\Model\UserId; use Neos\Neos\Domain\Repository\UserRepository; use Neos\Party\Domain\Model\AbstractParty; use Neos\Party\Domain\Model\PersonName; @@ -247,39 +247,21 @@ public function getCurrentUser(): ?User return null; } - $tokens = $this->securityContext->getAuthenticationTokens(); - $user = array_reduce($tokens, function ($foundUser, TokenInterface $token) { - if ($foundUser !== null) { - return $foundUser; - } - - /** @var ?Account $account */ + foreach ($this->securityContext->getAuthenticationTokens() as $token) { + /** @var Account|null $account */ $account = $token->getAccount(); if ($account === null) { - return $foundUser; + continue; } - $user = $this->getNeosUserForAccount($account); - if ($user === null) { - return $foundUser; + if ($user !== null) { + return $user; } - - return $user; - }, null); - - return $user; - } - - public function getCurrentUserIdentifier(): ?UserId - { - $currentUser = $this->getCurrentUser(); - - return $currentUser - ? UserId::fromString($this->persistenceManager->getIdentifierByObject($currentUser)) - : null; + } + return null; } - public function findByUserIdentifier(UserId $userId): ?User + public function findUserById(UserId $userId): ?User { /** @var ?User $user */ $user = $this->partyRepository->findByIdentifier($userId->value); @@ -389,8 +371,6 @@ public function deleteUser(User $user) $this->accountRepository->remove($account); } - $this->removeOwnerFromUsersWorkspaces($user); - $this->partyRepository->remove($user); $this->emitUserDeleted($user); } @@ -677,104 +657,6 @@ public function deactivateUser(User $user): void $this->emitUserDeactivated($user); } - /** - * Checks if the current user may publish to the given workspace according to one the roles of the user's accounts - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanPublishToWorkspace(Workspace $workspace): bool - { - if ($workspace->isPublicWorkspace()) { - return $this->securityContext->hasRole('Neos.Neos:LivePublisher'); - } - - $currentUser = $this->getCurrentUser(); - $ownerIdentifier = $currentUser - ? $this->persistenceManager->getIdentifierByObject($currentUser) - : null; - - if ($workspace->workspaceOwner === null || $workspace->workspaceOwner === $ownerIdentifier) { - return true; - } - - return false; - } - - /** - * Checks if the current user may read the given workspace according to one the roles of the user's accounts - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanReadWorkspace(Workspace $workspace): bool - { - if ($workspace->isPublicWorkspace()) { - return true; - } - - $currentUser = $this->getCurrentUser(); - - return $currentUser && $workspace->workspaceOwner - === $this->persistenceManager->getIdentifierByObject($currentUser); - } - - /** - * Checks if the current user may manage the given workspace according to one the roles of the user's accounts - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanManageWorkspace(Workspace $workspace): bool - { - if ($workspace->isPersonalWorkspace()) { - return false; - } - - if ($workspace->isInternalWorkspace()) { - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' - ); - } - - - $currentUser = $this->getCurrentUser(); - if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner === $this->persistenceManager->getIdentifierByObject($currentUser)) { - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageOwnWorkspaces' - ); - } - - if ($workspace->isPrivateWorkspace() && $currentUser !== null && $workspace->workspaceOwner !== $this->persistenceManager->getIdentifierByObject($currentUser)) { - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageAllPrivateWorkspaces' - ); - } - - return false; - } - - - /** - * Checks if the current user may transfer ownership of the given workspace - * - * In future versions, this logic may be implemented in Neos in a more generic way (for example, by means of an - * ACL object), but for now, this method exists in order to at least centralize and encapsulate the required logic. - */ - public function currentUserCanTransferOwnershipOfWorkspace(Workspace $workspace): bool - { - if ($workspace->isPersonalWorkspace()) { - return false; - } - - // The privilege to manage shared workspaces is needed, because regular editors should not change ownerships - // of their internal workspaces, even if it was technically possible, because they wouldn't be able to change - // ownership back to themselves. - return $this->privilegeManager->isPrivilegeTargetGranted( - 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' - ); - } - /** * @return bool * @throws NoSuchRoleException @@ -908,29 +790,6 @@ private function destroyActiveSessionsForUser(User $user, bool $keepCurrentSessi } } - /** - * Removes all personal workspaces of the given user's account if these workspaces exist. Also removes - * all possibly existing content of these workspaces. - * - * @param string $accountIdentifier Identifier of the user's account - * @return void - */ - protected function deletePersonalWorkspace($accountIdentifier) - { - // TODO - } - - /** - * Removes ownership of all workspaces currently owned by the given user - * - * @param User $user The user currently owning workspaces - * @return void - */ - protected function removeOwnerFromUsersWorkspaces(User $user) - { - // TODO - } - /** * @param string $username * @param string $authenticationProviderName diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceNameBuilder.php b/Neos.Neos/Classes/Domain/Service/WorkspaceNameBuilder.php deleted file mode 100644 index fc08f99cb26..00000000000 --- a/Neos.Neos/Classes/Domain/Service/WorkspaceNameBuilder.php +++ /dev/null @@ -1,35 +0,0 @@ -contentRepositoryRegistry->get($contentRepositoryId); + return $this->pendingWorkspaceChangesInternal($contentRepository, $workspaceName); + } + + /** + * @internal experimental api, until actually used by the Neos.Ui + */ + public function countPendingWorkspaceChanges(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): int + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + return $this->countPendingWorkspaceChangesInternal($contentRepository, $workspaceName); + } + + /** + * @throws WorkspaceDoesNotExist | WorkspaceRebaseFailed + */ + public function rebaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy = RebaseErrorHandlingStrategy::STRATEGY_FAIL): void + { + $rebaseCommand = RebaseWorkspace::create($workspaceName)->withErrorHandlingStrategy($rebaseErrorHandlingStrategy); + $this->contentRepositoryRegistry->get($contentRepositoryId)->handle($rebaseCommand); + } + + public function publishWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): PublishingResult + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + if ($crWorkspace->baseWorkspaceName === null) { + throw new \InvalidArgumentException(sprintf('Failed to publish workspace "%s" because it has no base workspace', $workspaceName->value), 1717517124); + } + $numberOfPendingChanges = $this->countPendingWorkspaceChangesInternal($contentRepository, $workspaceName); + $this->contentRepositoryRegistry->get($contentRepositoryId)->handle(PublishWorkspace::create($workspaceName)); + return new PublishingResult($numberOfPendingChanges, $crWorkspace->baseWorkspaceName); + } + + public function publishChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): PublishingResult + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + if ($crWorkspace->baseWorkspaceName === null) { + throw new \InvalidArgumentException(sprintf('Failed to publish workspace "%s" because it has no base workspace', $workspaceName->value), 1717517240); + } + $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); + $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, + $siteId, + $ancestorNodeTypeName + ); + + $nodeIdsToPublish = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, + $siteId, + $ancestorNodeTypeName + ); + + $this->publishNodes($contentRepository, $workspaceName, $nodeIdsToPublish); + + return new PublishingResult( + count($nodeIdsToPublish), + $crWorkspace->baseWorkspaceName, + ); + } + + public function publishChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): PublishingResult + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + if ($crWorkspace->baseWorkspaceName === null) { + throw new \InvalidArgumentException(sprintf('Failed to publish workspace "%s" because it has no base workspace', $workspaceName->value), 1717517467); + } + $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); + $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, + $documentId, + $ancestorNodeTypeName + ); + + $nodeIdsToPublish = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, + $documentId, + $ancestorNodeTypeName + ); + + $this->publishNodes($contentRepository, $workspaceName, $nodeIdsToPublish); + + return new PublishingResult( + count($nodeIdsToPublish), + $crWorkspace->baseWorkspaceName, + ); + } + + public function discardAllWorkspaceChanges(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): DiscardingResult + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + + $numberOfChangesToBeDiscarded = $this->countPendingWorkspaceChangesInternal($contentRepository, $workspaceName); + + $contentRepository->handle(DiscardWorkspace::create($workspaceName)); + + return new DiscardingResult($numberOfChangesToBeDiscarded); + } + + public function discardChangesInSite(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $siteId): DiscardingResult + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); + $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, + $siteId, + $ancestorNodeTypeName + ); + + $nodeIdsToDiscard = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, + $siteId, + NodeTypeNameFactory::forSite() + ); + + $this->discardNodes($contentRepository, $workspaceName, $nodeIdsToDiscard); + + return new DiscardingResult( + count($nodeIdsToDiscard) + ); + } + + public function discardChangesInDocument(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, NodeAggregateId $documentId): DiscardingResult + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); + $this->requireNodeToBeOfType( + $contentRepository, + $workspaceName, + $documentId, + $ancestorNodeTypeName + ); + + $nodeIdsToDiscard = $this->resolveNodeIdsToPublishOrDiscard( + $contentRepository, + $workspaceName, + $documentId, + $ancestorNodeTypeName + ); + + $this->discardNodes($contentRepository, $workspaceName, $nodeIdsToDiscard); + + return new DiscardingResult( + count($nodeIdsToDiscard) + ); + } + + public function changeBaseWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceName $newBaseWorkspaceName): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + $contentRepository->handle( + ChangeBaseWorkspace::create( + $workspaceName, + $newBaseWorkspaceName, + ) + ); + } + + private function discardNodes( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + NodeIdsToPublishOrDiscard $nodeIdsToDiscard + ): void { + /** + * TODO: only rebase if necessary! + * Also, isn't this already included in @see WorkspaceCommandHandler::handleDiscardIndividualNodesFromWorkspace ? + */ + $contentRepository->handle( + RebaseWorkspace::create($workspaceName) + ); + + $contentRepository->handle( + DiscardIndividualNodesFromWorkspace::create( + $workspaceName, + $nodeIdsToDiscard + ) + ); + } + + private function publishNodes( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + NodeIdsToPublishOrDiscard $nodeIdsToPublish + ): void { + /** + * TODO: only rebase if necessary! + * Also, isn't this already included in @see WorkspaceCommandHandler::handlePublishIndividualNodesFromWorkspace ? + */ + $contentRepository->handle( + RebaseWorkspace::create($workspaceName) + ); + + $contentRepository->handle( + PublishIndividualNodesFromWorkspace::create( + $workspaceName, + $nodeIdsToPublish + ) + ); + } + + private function requireContentRepositoryWorkspace( + ContentRepository $contentRepository, + WorkspaceName $workspaceName + ): ContentRepositoryWorkspace { + $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); + if (!$workspace instanceof ContentRepositoryWorkspace) { + throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); + } + return $workspace; + } + + private function requireNodeToBeOfType( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + NodeAggregateId $nodeAggregateId, + NodeTypeName $nodeTypeName, + ): void { + $nodeAggregate = $contentRepository->getContentGraph($workspaceName)->findNodeAggregateById( + $nodeAggregateId, + ); + if (!$nodeAggregate instanceof NodeAggregate) { + throw new NodeAggregateCurrentlyDoesNotExist( + 'Node aggregate ' . $nodeAggregateId->value . ' does currently not exist', + 1710967964 + ); + } + + if ( + !$contentRepository->getNodeTypeManager() + ->getNodeType($nodeAggregate->nodeTypeName) + ?->isOfType($nodeTypeName) + ) { + throw new \RuntimeException( + sprintf('Node aggregate %s is not of expected type %s', $nodeAggregateId->value, $nodeTypeName->value), + 1710968108 + ); + } + } + + /** + * @param NodeAggregateId $ancestorId The id of the ancestor node of all affected nodes + * @param NodeTypeName $ancestorNodeTypeName The type of the ancestor node of all affected nodes + */ + private function resolveNodeIdsToPublishOrDiscard( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + NodeAggregateId $ancestorId, + NodeTypeName $ancestorNodeTypeName + ): NodeIdsToPublishOrDiscard { + $nodeIdsToPublishOrDiscard = []; + foreach ($this->pendingWorkspaceChangesInternal($contentRepository, $workspaceName) as $change) { + if ( + !$this->isChangePublishableWithinAncestorScope( + $contentRepository, + $workspaceName, + $change, + $ancestorNodeTypeName, + $ancestorId + ) + ) { + continue; + } + + $nodeIdsToPublishOrDiscard[] = new NodeIdToPublishOrDiscard( + $change->nodeAggregateId, + $change->originDimensionSpacePoint->toDimensionSpacePoint() + ); + } + + return NodeIdsToPublishOrDiscard::create(...$nodeIdsToPublishOrDiscard); + } + + private function pendingWorkspaceChangesInternal(ContentRepository $contentRepository, WorkspaceName $workspaceName): Changes + { + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + return $contentRepository->projectionState(ChangeFinder::class)->findByContentStreamId($crWorkspace->currentContentStreamId); + } + + private function countPendingWorkspaceChangesInternal(ContentRepository $contentRepository, WorkspaceName $workspaceName): int + { + $crWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); + return $contentRepository->projectionState(ChangeFinder::class)->countByContentStreamId($crWorkspace->currentContentStreamId); + } + + + private function isChangePublishableWithinAncestorScope( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + Change $change, + NodeTypeName $ancestorNodeTypeName, + NodeAggregateId $ancestorId + ): bool { + // see method comment for `isChangeWithSelfReferencingRemovalAttachmentPoint` + // to get explanation for this condition + if ($this->isChangeWithSelfReferencingRemovalAttachmentPoint($change)) { + if ($ancestorNodeTypeName->equals(NodeTypeNameFactory::forSite())) { + return true; + } + } + + $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( + $change->originDimensionSpacePoint->toDimensionSpacePoint(), + VisibilityConstraints::withoutRestrictions() + ); + + // A Change is publishable if the respective node (or the respective + // removal attachment point) has a closest ancestor that matches our + // current ancestor scope (Document/Site) + $actualAncestorNode = $subgraph->findClosestNode( + $change->removalAttachmentPoint ?? $change->nodeAggregateId, + FindClosestNodeFilter::create(nodeTypes: $ancestorNodeTypeName->value) + ); + + return $actualAncestorNode?->aggregateId->equals($ancestorId) ?? false; + } + + /** + * Before the introduction of the {@see WorkspacePublishingService}, the UI only ever + * referenced the closest document node as a removal attachment point. + * + * Removed document nodes therefore were referencing themselves. + * + * In order to enable publish/discard of removed documents, the removal + * attachment point of a document MUST refer to an ancestor. The UI now + * references the site node in those cases. + * + * Workspaces that were created before this change was introduced may + * contain removed documents, for which the site node can longer be + * located, because we have no reference to their respective site. + * + * Every document node that matches that description will be published + * or discarded by {@see WorkspacePublishingService::publishChangesInSite()}, regardless of what + * the current site is. + * + * @deprecated remove once we are sure this check is no longer needed due to + * * the UI sending proper commands + * * the ChangeFinder being refactored / rewritten + * (whatever happens first) + */ + private function isChangeWithSelfReferencingRemovalAttachmentPoint(Change $change): bool + { + return $change->removalAttachmentPoint?->equals($change->nodeAggregateId) ?? false; + } +} diff --git a/Neos.Neos/Classes/Domain/Service/WorkspaceService.php b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php new file mode 100644 index 00000000000..5f854bc2689 --- /dev/null +++ b/Neos.Neos/Classes/Domain/Service/WorkspaceService.php @@ -0,0 +1,483 @@ +requireWorkspace($contentRepositoryId, $workspaceName); + $metadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); + return $metadata ?? new WorkspaceMetadata( + WorkspaceTitle::fromString($workspaceName->value), + WorkspaceDescription::fromString(''), + $workspace->baseWorkspaceName === null ? WorkspaceClassification::ROOT : WorkspaceClassification::UNKNOWN, + null, + ); + } + + /** + * Update/set title metadata for the specified workspace + */ + public function setWorkspaceTitle(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $newWorkspaceTitle): void + { + $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ + 'title' => $newWorkspaceTitle->value, + ]); + } + + /** + * Update/set description metadata for the specified workspace + */ + public function setWorkspaceDescription(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceDescription $newWorkspaceDescription): void + { + $this->updateWorkspaceMetadata($contentRepositoryId, $workspaceName, [ + 'description' => $newWorkspaceDescription->value, + ]); + } + + /** + * Retrieve the currently active personal workspace for the specified $userId + * + * NOTE: Currently there can only ever be a single personal workspace per user. But this API already prepares support for multiple personal workspaces per user + */ + public function getPersonalWorkspaceForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): Workspace + { + $workspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $userId); + if ($workspaceName === null) { + throw new \RuntimeException(sprintf('No workspace is assigned to the user with id "%s")', $userId->value), 1718293801); + } + return $this->requireWorkspace($contentRepositoryId, $workspaceName); + } + + /** + * Create a new root (aka base) workspace with the specified metadata + * + * @throws WorkspaceAlreadyExists + */ + public function createRootWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle( + CreateRootWorkspace::create( + $workspaceName, + DeprecatedWorkspaceTitle::fromString($title->value), + DeprecatedWorkspaceDescription::fromString($description->value), + ContentStreamId::create() + ) + ); + $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, WorkspaceClassification::ROOT, null); + } + + /** + * Create a new, personal, workspace for the specified user + */ + public function createPersonalWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId $ownerId): void + { + $this->createWorkspace($contentRepositoryId, $workspaceName, $title, $description, $baseWorkspaceName, $ownerId, WorkspaceClassification::PERSONAL); + } + + /** + * Create a new, potentially shared, workspace + */ + public function createSharedWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName): void + { + $this->createWorkspace($contentRepositoryId, $workspaceName, $title, $description, $baseWorkspaceName, null, WorkspaceClassification::SHARED); + } + + /** + * Create a new, personal, workspace for the specified user if none exists yet + * @internal experimental api, until actually used by the Neos.Ui + */ + public function createPersonalWorkspaceForUserIfMissing(ContentRepositoryId $contentRepositoryId, User $user): void + { + $existingWorkspaceName = $this->findPrimaryWorkspaceNameForUser($contentRepositoryId, $user->getId()); + if ($existingWorkspaceName !== null) { + $this->requireWorkspace($contentRepositoryId, $existingWorkspaceName); + return; + } + $workspaceName = $this->getUniqueWorkspaceName($contentRepositoryId, $user->getLabel()); + $this->createPersonalWorkspace( + $contentRepositoryId, + $workspaceName, + WorkspaceTitle::fromString($user->getLabel()), + WorkspaceDescription::empty(), + WorkspaceName::forLive(), + $user->getId(), + ); + } + + /** + * Assign a workspace role to the given user/user group + * + * Without explicit workspace roles, only administrators can change the corresponding workspace. + * With this method, the subject (i.e. a Neos user or group represented by a Flow role identifier) can be granted a {@see WorkspaceRole} for the specified workspace + */ + public function assignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleAssignment $assignment): void + { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $assignment->subjectType->value, + 'subject' => $assignment->subject->value, + 'role' => $assignment->role->value, + ]); + } catch (UniqueConstraintViolationException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): There is already a role assigned for that user/group, please unassign that first', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value), 1728476154, $e); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to assign role for workspace "%s" to subject "%s" (Content Repository "%s"): %s', $workspaceName->value, $assignment->subject->value, $contentRepositoryId->value, $e->getMessage()), 1728396138, $e); + } + } + + /** + * Remove a workspace role assignment for the given subject + * + * @see self::assignWorkspaceRole() + */ + public function unassignWorkspaceRole(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceRoleSubjectType $subjectType, WorkspaceRoleSubject $subject): void + { + $this->requireWorkspace($contentRepositoryId, $workspaceName); + try { + $affectedRows = $this->dbal->delete(self::TABLE_NAME_WORKSPACE_ROLE, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'subject_type' => $subjectType->value, + 'subject' => $subject->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): %s', $subject->value, $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728396169, $e); + } + if ($affectedRows === 0) { + throw new \RuntimeException(sprintf('Failed to unassign role for subject "%s" from workspace "%s" (Content Repository "%s"): No role assignment exists for this user/group', $subject->value, $workspaceName->value, $contentRepositoryId->value), 1728477071); + } + } + + /** + * Get all role assignments for the specified workspace + * + * NOTE: This should never be used to evaluate permissions, instead {@see self::getWorkspacePermissionsForUser()} should be used! + */ + public function getWorkspaceRoleAssignments(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): WorkspaceRoleAssignments + { + $table = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchAllAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to fetch workspace role assignments for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1728474440, $e); + } + return WorkspaceRoleAssignments::fromArray( + array_map(static fn (array $row) => WorkspaceRoleAssignment::create( + WorkspaceRoleSubjectType::from($row['subject_type']), + WorkspaceRoleSubject::fromString($row['subject']), + WorkspaceRole::from($row['role']), + ), $rows) + ); + } + + /** + * Determines the permission the given user has for the specified workspace {@see WorkspacePermissions} + */ + public function getWorkspacePermissionsForUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, User $user): WorkspacePermissions + { + try { + $userRoles = array_keys($this->userService->getAllRoles($user)); + } catch (NoSuchRoleException $e) { + throw new \RuntimeException(sprintf('Failed to determine roles for user "%s", check your package dependencies: %s', $user->getId()->value, $e->getMessage()), 1727084881, $e); + } + $userIsAdministrator = in_array('Neos.Neos:Administrator', $userRoles, true); + $workspaceMetadata = $this->loadWorkspaceMetadata($contentRepositoryId, $workspaceName); + if ($workspaceMetadata === null) { + return WorkspacePermissions::create(false, false, $userIsAdministrator); + } + if ($workspaceMetadata->ownerUserId !== null && $workspaceMetadata->ownerUserId->equals($user->getId())) { + return WorkspacePermissions::all(); + } + + $userWorkspaceRole = $this->loadWorkspaceRoleOfUser($contentRepositoryId, $workspaceName, $user->getId(), $userRoles); + if ($userWorkspaceRole === null) { + return WorkspacePermissions::create(false, false, $userIsAdministrator); + } + return WorkspacePermissions::create( + read: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + write: $userWorkspaceRole->isAtLeast(WorkspaceRole::COLLABORATOR), + manage: $userIsAdministrator || $userWorkspaceRole->isAtLeast(WorkspaceRole::MANAGER), + ); + } + + /** + * Builds a workspace name that is unique within the specified content repository. + * If $candidate already refers to a workspace name that is not used yet, it will be used (with transliteration to enforce a valid format) + * Otherwise a counter "-n" suffix is appended and increased until a unique name is found, or the maximum number of attempts has been reached (in which case an exception is thrown) + */ + public function getUniqueWorkspaceName(ContentRepositoryId $contentRepositoryId, string $candidate): WorkspaceName + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspaceNameCandidate = WorkspaceName::transliterateFromString($candidate); + $workspaceName = $workspaceNameCandidate; + $attempt = 1; + do { + if ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) === null) { + return $workspaceName; + } + if ($attempt === 1) { + $suffix = ''; + } else { + $suffix = '-' . ($attempt - 1); + } + $workspaceName = WorkspaceName::fromString( + substr($workspaceNameCandidate->value, 0, WorkspaceName::MAX_LENGTH - strlen($suffix)) . $suffix + ); + $attempt++; + } while ($attempt <= 10); + throw new \RuntimeException(sprintf('Failed to find unique workspace name for "%s" after %d attempts.', $candidate, $attempt - 1), 1725975479); + } + + // ------------------ + + private function loadWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): ?WorkspaceMetadata + { + $table = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchAssociative($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf( + 'Failed to fetch metadata for workspace "%s" (Content Repository "%s), please ensure the database schema is up to date. %s', + $workspaceName->value, + $contentRepositoryId->value, + $e->getMessage() + ), 1727782164, $e); + } + if (!is_array($metadataRow)) { + return null; + } + return new WorkspaceMetadata( + WorkspaceTitle::fromString($metadataRow['title']), + WorkspaceDescription::fromString($metadataRow['description']), + WorkspaceClassification::from($metadataRow['classification']), + $metadataRow['owner_user_id'] !== null ? UserId::fromString($metadataRow['owner_user_id']) : null, + ); + } + + /** + * @param array $data + */ + private function updateWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, array $data): void + { + $workspace = $this->requireWorkspace($contentRepositoryId, $workspaceName); + try { + $affectedRows = $this->dbal->update(self::TABLE_NAME_WORKSPACE_METADATA, $data, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + ]); + if ($affectedRows === 0) { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'description' => '', + 'title' => $workspaceName->value, + 'classification' => $workspace->baseWorkspaceName === null ? WorkspaceClassification::ROOT->value : WorkspaceClassification::UNKNOWN->value, + ...$data, + ]); + } + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to update metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1726821159, $e); + } + } + + private function createWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceName $baseWorkspaceName, UserId|null $ownerId, WorkspaceClassification $classification): void + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $contentRepository->handle( + CreateWorkspace::create( + $workspaceName, + $baseWorkspaceName, + DeprecatedWorkspaceTitle::fromString($title->value), + DeprecatedWorkspaceDescription::fromString($description->value), + ContentStreamId::create() + ) + ); + $this->addWorkspaceMetadata($contentRepositoryId, $workspaceName, $title, $description, $classification, $ownerId); + } + + private function addWorkspaceMetadata(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, WorkspaceClassification $classification, UserId|null $ownerUserId): void + { + try { + $this->dbal->insert(self::TABLE_NAME_WORKSPACE_METADATA, [ + 'content_repository_id' => $contentRepositoryId->value, + 'workspace_name' => $workspaceName->value, + 'title' => $title->value, + 'description' => $description->value, + 'classification' => $classification->value, + 'owner_user_id' => $ownerUserId?->value, + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to add metadata for workspace "%s" (Content Repository "%s"): %s', $workspaceName->value, $contentRepositoryId->value, $e->getMessage()), 1727084068, $e); + } + } + + private function findPrimaryWorkspaceNameForUser(ContentRepositoryId $contentRepositoryId, UserId $userId): ?WorkspaceName + { + $tableMetadata = self::TABLE_NAME_WORKSPACE_METADATA; + $query = <<dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'personalWorkspaceClassification' => WorkspaceClassification::PERSONAL->value, + 'userId' => $userId->value, + ]); + return $workspaceName === false ? null : WorkspaceName::fromString($workspaceName); + } + + /** + * @param array $userRoles + */ + private function loadWorkspaceRoleOfUser(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName, UserId $userId, array $userRoles): ?WorkspaceRole + { + $tableRole = self::TABLE_NAME_WORKSPACE_ROLE; + $query = <<dbal->fetchOne($query, [ + 'contentRepositoryId' => $contentRepositoryId->value, + 'workspaceName' => $workspaceName->value, + 'userSubjectType' => WorkspaceRoleSubjectType::USER->value, + 'userId' => $userId->value, + 'groupSubjectType' => WorkspaceRoleSubjectType::GROUP->value, + 'groupSubjects' => $userRoles, + ], [ + 'groupSubjects' => ArrayParameterType::STRING, + ]); + if ($role === false) { + return null; + } + return WorkspaceRole::from($role); + } + + private function requireWorkspace(ContentRepositoryId $contentRepositoryId, WorkspaceName $workspaceName): Workspace + { + $workspace = $this->contentRepositoryRegistry + ->get($contentRepositoryId) + ->getWorkspaceFinder() + ->findOneByName($workspaceName); + if ($workspace === null) { + throw new \RuntimeException(sprintf('Failed to find workspace with name "%s" for content repository "%s"', $workspaceName->value, $contentRepositoryId->value), 1718379722); + } + return $workspace; + } +} diff --git a/Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php b/Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php deleted file mode 100644 index 89a4cbb80b5..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/DiscardAllChanges.php +++ /dev/null @@ -1,45 +0,0 @@ - $values - */ - public static function fromArray(array $values): self - { - return new self( - ContentRepositoryId::fromString($values['contentRepositoryId']), - WorkspaceName::fromString($values['workspaceName']), - ); - } -} diff --git a/Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php b/Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php deleted file mode 100644 index 586350f0d8d..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/PublishAllChanges.php +++ /dev/null @@ -1,45 +0,0 @@ - $values - */ - public static function fromArray(array $values): self - { - return new self( - ContentRepositoryId::fromString($values['contentRepositoryId']), - WorkspaceName::fromString($values['workspaceName']), - ); - } -} diff --git a/Neos.Neos/Classes/Domain/Workspace/Workspace.php b/Neos.Neos/Classes/Domain/Workspace/Workspace.php deleted file mode 100644 index 9d3bd125be8..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/Workspace.php +++ /dev/null @@ -1,457 +0,0 @@ -contentRepositoryId = $contentRepository->id; - } - - public function getCurrentStatus(): WorkspaceStatus - { - return $this->currentStatus; - } - - public function getCurrentBaseWorkspaceName(): ?WorkspaceName - { - return $this->currentBaseWorkspaceName; - } - - /** @internal experimental api, until actually used by the Neos.Ui */ - public function countAllChanges(): int - { - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); - - return count($changes); - } - - /** @internal experimental api, until actually used by the Neos.Ui */ - public function countChangesInSite(NodeAggregateId $siteId): int - { - $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); - $this->requireNodeToBeOfType( - $siteId, - $ancestorNodeTypeName - ); - - $changes = $this->resolveNodeIdsToPublishOrDiscard( - $siteId, - $ancestorNodeTypeName - ); - - return count($changes); - } - - /** @internal experimental api, until actually used by the Neos.Ui */ - public function countChangesInDocument(NodeAggregateId $documentId): int - { - $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); - $this->requireNodeToBeOfType( - $documentId, - $ancestorNodeTypeName - ); - - $changes = $this->resolveNodeIdsToPublishOrDiscard( - $documentId, - $ancestorNodeTypeName - ); - - return count($changes); - } - - /** @internal should not be necessary in user land code */ - public function getCurrentContentStreamId(): ContentStreamId - { - return $this->currentContentStreamId; - } - - public function publishAllChanges(): PublishingResult - { - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); - - $this->publish(); - - return new PublishingResult( - count($changes) - ); - } - - public function publishChangesInSite(NodeAggregateId $siteId): PublishingResult - { - $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); - $this->requireNodeToBeOfType( - $siteId, - $ancestorNodeTypeName - ); - - $nodeIdsToPublish = $this->resolveNodeIdsToPublishOrDiscard( - $siteId, - $ancestorNodeTypeName - ); - - $this->publishNodes($nodeIdsToPublish); - - return new PublishingResult( - count($nodeIdsToPublish) - ); - } - - public function publishChangesInDocument(NodeAggregateId $documentId): PublishingResult - { - $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); - $this->requireNodeToBeOfType( - $documentId, - $ancestorNodeTypeName - ); - - $nodeIdsToPublish = $this->resolveNodeIdsToPublishOrDiscard( - $documentId, - $ancestorNodeTypeName - ); - - $this->publishNodes($nodeIdsToPublish); - - return new PublishingResult( - count($nodeIdsToPublish) - ); - } - - public function discardAllChanges(): DiscardingResult - { - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); - - $this->discard(); - - return new DiscardingResult( - count($changes) - ); - } - - public function discardChangesInSite(NodeAggregateId $siteId): DiscardingResult - { - $ancestorNodeTypeName = NodeTypeNameFactory::forSite(); - $this->requireNodeToBeOfType( - $siteId, - $ancestorNodeTypeName - ); - - $nodeIdsToDiscard = $this->resolveNodeIdsToPublishOrDiscard( - $siteId, - NodeTypeNameFactory::forSite() - ); - - $this->discardNodes($nodeIdsToDiscard); - - return new DiscardingResult( - count($nodeIdsToDiscard) - ); - } - - public function discardChangesInDocument(NodeAggregateId $documentId): DiscardingResult - { - $ancestorNodeTypeName = NodeTypeNameFactory::forDocument(); - $this->requireNodeToBeOfType( - $documentId, - $ancestorNodeTypeName - ); - - $nodeIdsToDiscard = $this->resolveNodeIdsToPublishOrDiscard( - $documentId, - $ancestorNodeTypeName - ); - - $this->discardNodes($nodeIdsToDiscard); - - return new DiscardingResult( - count($nodeIdsToDiscard) - ); - } - - /** - * @throws WorkspaceRebaseFailed - */ - public function rebase(RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy = RebaseErrorHandlingStrategy::STRATEGY_FAIL): void - { - $rebaseCommand = RebaseWorkspace::create( - $this->name - )->withErrorHandlingStrategy($rebaseErrorHandlingStrategy); - - $this->contentRepository->handle($rebaseCommand); - - $this->updateCurrentState(); - } - - public function changeBaseWorkspace(WorkspaceName $baseWorkspaceName): void - { - $this->contentRepository->handle( - ChangeBaseWorkspace::create( - $this->name, - $baseWorkspaceName - ) - ); - - $this->updateCurrentState(); - } - - private function requireNodeToBeOfType( - NodeAggregateId $nodeAggregateId, - NodeTypeName $nodeTypeName, - ): void { - $nodeAggregate = $this->contentRepository->getContentGraph($this->name)->findNodeAggregateById( - $nodeAggregateId, - ); - if (!$nodeAggregate instanceof NodeAggregate) { - throw new NodeAggregateCurrentlyDoesNotExist( - 'Node aggregate ' . $nodeAggregateId->value . ' does currently not exist', - 1710967964 - ); - } - - if ( - !$this->contentRepository->getNodeTypeManager() - ->getNodeType($nodeAggregate->nodeTypeName) - ?->isOfType($nodeTypeName) - ) { - throw new \DomainException( - 'Node aggregate ' . $nodeAggregateId->value . ' is not of expected type ' . $nodeTypeName->value, - 1710968108 - ); - } - } - - private function publish(): void - { - $this->contentRepository->handle( - PublishWorkspace::create( - $this->name, - ) - ); - - $this->updateCurrentState(); - } - - private function publishNodes( - NodeIdsToPublishOrDiscard $nodeIdsToPublish - ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handlePublishIndividualNodesFromWorkspace ? - */ - $this->contentRepository->handle( - RebaseWorkspace::create( - $this->name - ) - ); - - $this->contentRepository->handle( - PublishIndividualNodesFromWorkspace::create( - $this->name, - $nodeIdsToPublish - ) - ); - - $this->updateCurrentState(); - } - - private function discard(): void - { - $this->contentRepository->handle( - DiscardWorkspace::create( - $this->name, - ) - ); - - $this->updateCurrentState(); - } - - private function discardNodes( - NodeIdsToPublishOrDiscard $nodeIdsToDiscard - ): void { - /** - * TODO: only rebase if necessary! - * Also, isn't this already included in @see WorkspaceCommandHandler::handleDiscardIndividualNodesFromWorkspace ? - */ - $this->contentRepository->handle( - RebaseWorkspace::create( - $this->name - ) - ); - - $this->contentRepository->handle( - DiscardIndividualNodesFromWorkspace::create( - $this->name, - $nodeIdsToDiscard - ) - ); - - $this->updateCurrentState(); - } - - /** - * @param NodeAggregateId $ancestorId The id of the ancestor node of all affected nodes - * @param NodeTypeName $ancestorNodeTypeName The type of the ancestor node of all affected nodes - */ - private function resolveNodeIdsToPublishOrDiscard( - NodeAggregateId $ancestorId, - NodeTypeName $ancestorNodeTypeName - ): NodeIdsToPublishOrDiscard { - /** @var ChangeFinder $changeFinder */ - $changeFinder = $this->contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($this->currentContentStreamId); - $nodeIdsToPublishOrDiscard = []; - foreach ($changes as $change) { - if ( - !$this->isChangePublishableWithinAncestorScope( - $change, - $ancestorNodeTypeName, - $ancestorId - ) - ) { - continue; - } - - $nodeIdsToPublishOrDiscard[] = new NodeIdToPublishOrDiscard( - $change->nodeAggregateId, - $change->originDimensionSpacePoint->toDimensionSpacePoint() - ); - } - - return NodeIdsToPublishOrDiscard::create(...$nodeIdsToPublishOrDiscard); - } - - private function isChangePublishableWithinAncestorScope( - Change $change, - NodeTypeName $ancestorNodeTypeName, - NodeAggregateId $ancestorId - ): bool { - // see method comment for `isChangeWithSelfReferencingRemovalAttachmentPoint` - // to get explanation for this condition - if ($this->isChangeWithSelfReferencingRemovalAttachmentPoint($change)) { - if ($ancestorNodeTypeName->equals(NodeTypeNameFactory::forSite())) { - return true; - } - } - - $subgraph = $this->contentRepository->getContentGraph($this->name)->getSubgraph( - $change->originDimensionSpacePoint->toDimensionSpacePoint(), - VisibilityConstraints::withoutRestrictions() - ); - - // A Change is publishable if the respective node (or the respective - // removal attachment point) has a closest ancestor that matches our - // current ancestor scope (Document/Site) - $actualAncestorNode = $subgraph->findClosestNode( - $change->removalAttachmentPoint ?? $change->nodeAggregateId, - FindClosestNodeFilter::create(nodeTypes: $ancestorNodeTypeName->value) - ); - - return $actualAncestorNode?->aggregateId->equals($ancestorId) ?? false; - } - - /** - * Before the introduction of the WorkspacePublisher, the UI only ever - * referenced the closest document node as a removal attachment point. - * - * Removed document nodes therefore were referencing themselves. - * - * In order to enable publish/discard of removed documents, the removal - * attachment point of a document MUST refer to an ancestor. The UI now - * references the site node in those cases. - * - * Workspaces that were created before this change was introduced may - * contain removed documents, for which the site node can longer be - * located, because we have no reference to their respective site. - * - * Every document node that matches that description will be published - * or discarded by WorkspacePublisher::publishSite, regardless of what - * the current site is. - * - * @deprecated remove once we are sure this check is no longer needed due to - * * the UI sending proper commands - * * the ChangeFinder being refactored / rewritten - * (whatever happens first) - */ - private function isChangeWithSelfReferencingRemovalAttachmentPoint(Change $change): bool - { - return $change->removalAttachmentPoint?->equals($change->nodeAggregateId) ?? false; - } - - private function updateCurrentState(): void - { - /** The workspace projection should have been marked stale via @see WithMarkStaleInterface in the meantime */ - $contentRepositoryWorkspace = $this->contentRepository->getWorkspaceFinder() - ->findOneByName($this->name); - if (!$contentRepositoryWorkspace) { - throw new WorkspaceDoesNotExist('Cannot update state of non-existent workspace ' . $this->name->value, 1711704397); - } - - $this->currentContentStreamId = $contentRepositoryWorkspace->currentContentStreamId; - $this->currentStatus = $contentRepositoryWorkspace->status; - $this->currentBaseWorkspaceName = $contentRepositoryWorkspace->baseWorkspaceName; - } -} diff --git a/Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php b/Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php deleted file mode 100644 index 257a1233410..00000000000 --- a/Neos.Neos/Classes/Domain/Workspace/WorkspaceProvider.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - private array $instances; - - public function __construct( - private readonly ContentRepositoryRegistry $contentRepositoryRegistry - ) { - } - - public function provideForWorkspaceName( - ContentRepositoryId $contentRepositoryId, - WorkspaceName $workspaceName, - ): Workspace { - $index = $contentRepositoryId->value . '-' . $workspaceName->value; - if (isset($this->instances[$index])) { - return $this->instances[$index]; - } - - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $contentRepositoryWorkspace = $this->requireContentRepositoryWorkspace($contentRepository, $workspaceName); - - return $this->instances[$index] = new Workspace( - $workspaceName, - $contentRepositoryWorkspace->currentContentStreamId, - $contentRepositoryWorkspace->status, - $contentRepositoryWorkspace->baseWorkspaceName, - $contentRepository - ); - } - - private function requireContentRepositoryWorkspace( - ContentRepository $contentRepository, - WorkspaceName $workspaceName - ): ContentRepositoryWorkspace { - $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); - if (!$workspace instanceof ContentRepositoryWorkspace) { - throw WorkspaceDoesNotExist::butWasSupposedTo($workspaceName); - } - - // @todo: access control goes here - - return $workspace; - } -} diff --git a/Neos.Neos/Classes/PendingChangesProjection/Change.php b/Neos.Neos/Classes/PendingChangesProjection/Change.php index 9473d7a9b93..acee1cda5e6 100644 --- a/Neos.Neos/Classes/PendingChangesProjection/Change.php +++ b/Neos.Neos/Classes/PendingChangesProjection/Change.php @@ -15,6 +15,7 @@ namespace Neos\Neos\PendingChangesProjection; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception as DbalException; use Neos\ContentRepository\Core\DimensionSpace\OriginDimensionSpacePoint; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; @@ -22,71 +23,26 @@ use Neos\Flow\Annotations as Flow; /** - * Change Read Model + * Read model for pending changes * - * !!! Still a bit unstable - might change in the future. + * @internal !!! Still a bit unstable - might change in the future. * @Flow\Proxy(false) */ -class Change +final class Change { /** - * @var ContentStreamId + * @param NodeAggregateId|null $removalAttachmentPoint {@see RemoveNodeAggregate::$removalAttachmentPoint} for docs */ - public $contentStreamId; - - /** - * @var NodeAggregateId - */ - public $nodeAggregateId; - - /** - * @var OriginDimensionSpacePoint - */ - public $originDimensionSpacePoint; - - /** - * @var bool - */ - public $created; - - /** - * @var bool - */ - public $changed; - - /** - * @var bool - */ - public $moved; - - /** - * @var bool - */ - public $deleted; - - /** - * {@see RemoveNodeAggregate::$removalAttachmentPoint} for docs - */ - public ?NodeAggregateId $removalAttachmentPoint; - public function __construct( - ContentStreamId $contentStreamId, - NodeAggregateId $nodeAggregateId, - OriginDimensionSpacePoint $originDimensionSpacePoint, - bool $created, - bool $changed, - bool $moved, - bool $deleted, - ?NodeAggregateId $removalAttachmentPoint = null + public ContentStreamId $contentStreamId, + public NodeAggregateId $nodeAggregateId, + public OriginDimensionSpacePoint $originDimensionSpacePoint, + public bool $created, + public bool $changed, + public bool $moved, + public bool $deleted, + public ?NodeAggregateId $removalAttachmentPoint = null ) { - $this->contentStreamId = $contentStreamId; - $this->nodeAggregateId = $nodeAggregateId; - $this->originDimensionSpacePoint = $originDimensionSpacePoint; - $this->created = $created; - $this->changed = $changed; - $this->moved = $moved; - $this->deleted = $deleted; - $this->removalAttachmentPoint = $removalAttachmentPoint; } @@ -95,37 +51,45 @@ public function __construct( */ public function addToDatabase(Connection $databaseConnection, string $tableName): void { - $databaseConnection->insert($tableName, [ - 'contentStreamId' => $this->contentStreamId->value, - 'nodeAggregateId' => $this->nodeAggregateId->value, - 'originDimensionSpacePoint' => $this->originDimensionSpacePoint->toJson(), - 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint->hash, - 'created' => (int)$this->created, - 'changed' => (int)$this->changed, - 'moved' => (int)$this->moved, - 'deleted' => (int)$this->deleted, - 'removalAttachmentPoint' => $this->removalAttachmentPoint?->value - ]); - } - - public function updateToDatabase(Connection $databaseConnection, string $tableName): void - { - $databaseConnection->update( - $tableName, - [ + try { + $databaseConnection->insert($tableName, [ + 'contentStreamId' => $this->contentStreamId->value, + 'nodeAggregateId' => $this->nodeAggregateId->value, + 'originDimensionSpacePoint' => $this->originDimensionSpacePoint->toJson(), + 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint->hash, 'created' => (int)$this->created, 'changed' => (int)$this->changed, 'moved' => (int)$this->moved, 'deleted' => (int)$this->deleted, 'removalAttachmentPoint' => $this->removalAttachmentPoint?->value - ], - [ - 'contentStreamId' => $this->contentStreamId->value, - 'nodeAggregateId' => $this->nodeAggregateId->value, - 'originDimensionSpacePoint' => $this->originDimensionSpacePoint->toJson(), - 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint->hash, - ] - ); + ]); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to insert Change to database: %s', $e->getMessage()), 1727272723, $e); + } + } + + public function updateToDatabase(Connection $databaseConnection, string $tableName): void + { + try { + $databaseConnection->update( + $tableName, + [ + 'created' => (int)$this->created, + 'changed' => (int)$this->changed, + 'moved' => (int)$this->moved, + 'deleted' => (int)$this->deleted, + 'removalAttachmentPoint' => $this->removalAttachmentPoint?->value + ], + [ + 'contentStreamId' => $this->contentStreamId->value, + 'nodeAggregateId' => $this->nodeAggregateId->value, + 'originDimensionSpacePoint' => $this->originDimensionSpacePoint->toJson(), + 'originDimensionSpacePointHash' => $this->originDimensionSpacePoint->hash, + ] + ); + } catch (DbalException $e) { + throw new \RuntimeException(sprintf('Failed to update Change in database: %s', $e->getMessage()), 1727272761, $e); + } } /** diff --git a/Neos.Neos/Classes/PendingChangesProjection/ChangeFinder.php b/Neos.Neos/Classes/PendingChangesProjection/ChangeFinder.php index 512eb585b29..f25e288f4e4 100644 --- a/Neos.Neos/Classes/PendingChangesProjection/ChangeFinder.php +++ b/Neos.Neos/Classes/PendingChangesProjection/ChangeFinder.php @@ -34,38 +34,30 @@ public function __construct( ) { } - /** - * @param ContentStreamId $contentStreamId - * @return array|Change[] - */ - public function findByContentStreamId(ContentStreamId $contentStreamId): array + public function findByContentStreamId(ContentStreamId $contentStreamId): Changes { $changeRows = $this->dbal->executeQuery( - ' - SELECT * FROM ' . $this->tableName . ' + <<tableName} WHERE contentStreamId = :contentStreamId - ', + SQL, [ 'contentStreamId' => $contentStreamId->value ] - )->fetchAll(); - $changes = []; - foreach ($changeRows as $changeRow) { - $changes[] = Change::fromDatabaseRow($changeRow); - } - return $changes; + )->fetchAllAssociative(); + return Changes::fromArray(array_map(Change::fromDatabaseRow(...), $changeRows)); } public function countByContentStreamId(ContentStreamId $contentStreamId): int { - return (int)$this->dbal->executeQuery( - ' - SELECT * FROM ' . $this->tableName . ' + return (int)$this->dbal->fetchOne( + <<tableName} WHERE contentStreamId = :contentStreamId - ', + SQL, [ 'contentStreamId' => $contentStreamId->value ] - )->rowCount(); + ); } } diff --git a/Neos.Neos/Classes/PendingChangesProjection/Changes.php b/Neos.Neos/Classes/PendingChangesProjection/Changes.php new file mode 100644 index 00000000000..956e9e37210 --- /dev/null +++ b/Neos.Neos/Classes/PendingChangesProjection/Changes.php @@ -0,0 +1,61 @@ + + */ +final readonly class Changes implements \IteratorAggregate, \Countable +{ + /** + * @param list $changes + */ + private function __construct( + private array $changes + ) { + } + + /** + * @param list $changes + */ + public static function fromArray(array $changes): self + { + foreach ($changes as $change) { + if (!$change instanceof Change) { + throw new \InvalidArgumentException(sprintf('Changes can only consist of %s instances, given: %s', Change::class, get_debug_type($change)), 1727273148); + } + } + return new self($changes); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield from $this->changes; + } + + public function count(): int + { + return count($this->changes); + } +} diff --git a/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php b/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php index f4e18d0a96d..4ea58ba6d74 100644 --- a/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php +++ b/Neos.Neos/Classes/Service/EditorContentStreamZookeeper.php @@ -14,22 +14,12 @@ namespace Neos\Neos\Service; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; -use Neos\ContentRepository\Core\SharedModel\User\UserId; -use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; use Neos\Flow\Http\HttpRequestHandlerInterface; -use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\Security\Authentication; use Neos\Flow\Security\Policy\PolicyService; -use Neos\Flow\Security\Policy\Role; -use Neos\Neos\Domain\Model\User; -use Neos\Neos\Domain\Service\WorkspaceNameBuilder; +use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionFailedException; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Party\Domain\Service\PartyService; @@ -46,15 +36,15 @@ final class EditorContentStreamZookeeper { /** * @Flow\Inject - * @var PersistenceManagerInterface + * @var PartyService */ - protected $persistenceManager; + protected $partyService; /** * @Flow\Inject - * @var PartyService + * @var \Neos\Neos\Domain\Service\UserService */ - protected $partyService; + protected $userService; /** * @Flow\Inject @@ -70,9 +60,9 @@ final class EditorContentStreamZookeeper /** * @Flow\Inject - * @var ContentRepositoryRegistry + * @var WorkspaceService */ - protected $contentRepositoryRegistry; + protected $workspaceService; /** * This method is called whenever a login happens (AuthenticationProviderManager::class, 'authenticatedToken'), @@ -97,45 +87,14 @@ public function relayEditorAuthentication(Authentication\TokenInterface $token): } catch (SiteDetectionFailedException) { return; } - $contentRepository = $this->contentRepositoryRegistry->get($siteDetectionResult->contentRepositoryId); - $isEditor = false; - foreach ($token->getAccount()->getRoles() as $role) { - /** @var Role $role */ - if (isset($role->getAllParentRoles()['Neos.Neos:AbstractEditor'])) { - $isEditor = true; - break; - } - } - if (!$isEditor) { + $authenticatedUser = $this->userService->getCurrentUser(); + if ($authenticatedUser === null) { return; } - $user = $this->partyService->getAssignedPartyOfAccount($token->getAccount()); - if (!$user instanceof User) { - return; - } - $workspaceName = WorkspaceNameBuilder::fromAccountIdentifier( - $token->getAccount()->getAccountIdentifier() - ); - $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); - if ($workspace !== null) { - return; - } - - $baseWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName(WorkspaceName::forLive()); - if (!$baseWorkspace) { + if (!array_key_exists('Neos.Neos:AbstractEditor', $this->userService->getAllRoles($authenticatedUser))) { return; } - $editorsNewContentStreamId = ContentStreamId::create(); - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspace->workspaceName, - new WorkspaceTitle((string) $user->getName()), - new WorkspaceDescription(''), - $editorsNewContentStreamId, - UserId::fromString($this->persistenceManager->getIdentifierByObject($user)) - ) - ); + $this->workspaceService->createPersonalWorkspaceForUserIfMissing($siteDetectionResult->contentRepositoryId, $authenticatedUser); } } diff --git a/Neos.Neos/Classes/Service/UserService.php b/Neos.Neos/Classes/Service/UserService.php index 3e893dbcd88..5326ad93220 100644 --- a/Neos.Neos/Classes/Service/UserService.php +++ b/Neos.Neos/Classes/Service/UserService.php @@ -15,9 +15,7 @@ namespace Neos\Neos\Service; use Neos\Flow\Annotations as Flow; -use Neos\Flow\Security\Account; use Neos\Neos\Domain\Model\User; -use Neos\Neos\Utility\User as UserUtility; /** * The user service provides general context information about the currently @@ -61,33 +59,6 @@ public function getBackendUser() return $this->userDomainService->getCurrentUser(); } - /** - * Returns the name of the currently logged in user's personal workspace - * (even if that might not exist at that time). - * If no user is logged in this method returns null. - * - * @api - */ - public function getPersonalWorkspaceName(): ?string - { - $currentUser = $this->userDomainService->getCurrentUser(); - - if (!$currentUser instanceof User) { - return null; - } - /** @var ?Account $currentAccount */ - $currentAccount = $this->securityContext->getAccount(); - if ($currentAccount === null) { - return null; - } - - $username = $this->userDomainService->getUsername( - $currentUser, - $currentAccount->getAuthenticationProviderName() - ); - return ($username === null ? null : UserUtility::getPersonalWorkspaceNameForUsername($username)); - } - /** * Returns the stored preferences of a user * diff --git a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php index 6b1da68e7a9..b81c97e8c5a 100644 --- a/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php +++ b/Neos.Neos/Classes/UserIdProvider/UserIdProvider.php @@ -20,10 +20,10 @@ public function __construct( public function getUserId(): UserId { - $userId = $this->userService->getCurrentUserIdentifier(); - if ($userId === null) { + $user = $this->userService->getCurrentUser(); + if ($user === null) { return UserId::forSystemUser(); } - return UserId::fromString($userId->value); + return UserId::fromString($user->getId()->value); } } diff --git a/Neos.Neos/Classes/Utility/User.php b/Neos.Neos/Classes/Utility/User.php deleted file mode 100644 index 5275452f696..00000000000 --- a/Neos.Neos/Classes/Utility/User.php +++ /dev/null @@ -1,39 +0,0 @@ -value; - } - - /** - * Will reduce the username to ascii alphabet and numbers. - * - * @deprecated with Neos 9.0 please implement your own slug genration. You might also want to look into transliteration with {@see \Behat\Transliterator\Transliterator}. - * @param string $username - * @return string - */ - public static function slugifyUsername($username): string - { - return preg_replace('/[^a-z0-9]/i', '', $username) ?: ''; - } -} diff --git a/Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php b/Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php deleted file mode 100644 index 6d4493e4773..00000000000 --- a/Neos.Neos/Classes/ViewHelpers/Backend/ChangeStatsViewHelper.php +++ /dev/null @@ -1,65 +0,0 @@ -registerArgument('changeCounts', 'array', 'Expected keys: new, changed, removed', true); - } - - /** - * Expects an array of change count data and adds calculated ratios to the rendered child view - * - * @return string - */ - public function render(): string - { - $changeCounts = $this->arguments['changeCounts']; - - $this->templateVariableContainer->add('newCountRatio', $changeCounts['new'] / $changeCounts['total'] * 100); - $this->templateVariableContainer->add( - 'changedCountRatio', - $changeCounts['changed'] / $changeCounts['total'] * 100 - ); - $this->templateVariableContainer->add( - 'removedCountRatio', - $changeCounts['removed'] / $changeCounts['total'] * 100 - ); - $content = $this->renderChildren(); - $this->templateVariableContainer->remove('newCountRatio'); - $this->templateVariableContainer->remove('changedCountRatio'); - $this->templateVariableContainer->remove('removedCountRatio'); - - return $content; - } -} diff --git a/Neos.Neos/Migrations/Mysql/Version20151223125909.php b/Neos.Neos/Migrations/Mysql/Version20151223125909.php index 3e7b2c61bfc..b7b9fddc70f 100644 --- a/Neos.Neos/Migrations/Mysql/Version20151223125909.php +++ b/Neos.Neos/Migrations/Mysql/Version20151223125909.php @@ -5,7 +5,6 @@ use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -use Neos\Neos\Utility\User as UserUtility; use PDO; /** @@ -34,7 +33,7 @@ public function up(Schema $schema): void $neosAccountQuery = $this->connection->executeQuery('SELECT t0.party_abstractparty, t1.accountidentifier FROM typo3_party_domain_model_abstractparty_accounts_join t0 JOIN typo3_flow_security_account t1 ON t0.flow_security_account = t1.persistence_object_identifier WHERE t1.authenticationprovidername = \'Typo3BackendProvider\''); while ($account = $neosAccountQuery->fetch(PDO::FETCH_ASSOC)) { - $normalizedUsername = UserUtility::slugifyUsername($account['accountidentifier']); + $normalizedUsername = preg_replace('/[^a-z0-9]/i', '', $account['accountidentifier']) ?: ''; foreach ($workspacesWithoutOwner as $workspaceWithoutOwner) { if ($workspaceWithoutOwner['name'] === 'user-' . $normalizedUsername) { diff --git a/Neos.Neos/Migrations/Mysql/Version20160104121311.php b/Neos.Neos/Migrations/Mysql/Version20160104121311.php index d18a45b32b6..6075ad206f2 100644 --- a/Neos.Neos/Migrations/Mysql/Version20160104121311.php +++ b/Neos.Neos/Migrations/Mysql/Version20160104121311.php @@ -5,7 +5,6 @@ use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -use Neos\Neos\Utility\User as UserUtility; use PDO; /** @@ -34,7 +33,7 @@ public function up(Schema $schema): void $neosAccountQuery = $this->connection->executeQuery('SELECT t0.party_abstractparty, t1.accountidentifier FROM typo3_party_domain_model_abstractparty_accounts_join t0 JOIN typo3_flow_security_account t1 ON t0.flow_security_account = t1.persistence_object_identifier WHERE t1.authenticationprovidername = \'Typo3BackendProvider\''); while ($account = $neosAccountQuery->fetch(PDO::FETCH_ASSOC)) { - $normalizedUsername = UserUtility::slugifyUsername($account['accountidentifier']); + $normalizedUsername = preg_replace('/[^a-z0-9]/i', '', $account['accountidentifier']) ?: ''; foreach ($workspacesWithoutOwner as $workspaceWithoutOwner) { if ($workspaceWithoutOwner['name'] === 'user-' . $normalizedUsername) { diff --git a/Neos.Neos/Migrations/Mysql/Version20240425223900.php b/Neos.Neos/Migrations/Mysql/Version20240425223900.php new file mode 100644 index 00000000000..69bd0305bcd --- /dev/null +++ b/Neos.Neos/Migrations/Mysql/Version20240425223900.php @@ -0,0 +1,53 @@ +abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MariaDb1027Platform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MariaDb1027Platform'." + ); + + $tableWorkspaceMetadata = $schema->createTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceMetadata->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('title', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('description', 'text'); + $tableWorkspaceMetadata->addColumn('classification', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('owner_user_id', 'string', ['length' => 255, 'notnull' => false]); + $tableWorkspaceMetadata->setPrimaryKey(['content_repository_id', 'workspace_name']); + $tableWorkspaceMetadata->addIndex(['owner_user_id']); + + $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); + $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceRole->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('subject_type', 'string', ['length' => 20]); + $tableWorkspaceRole->addColumn('subject', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('role', 'string', ['length' => 20]); + $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject_type', 'subject']); + } + + public function down(Schema $schema): void + { + $this->abortIf( + !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MariaDb1027Platform, + "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MariaDb1027Platform'." + ); + + $schema->dropTable('neos_neos_workspace_role'); + $schema->dropTable('neos_neos_workspace_metadata'); + } +} diff --git a/Neos.Neos/Migrations/Postgresql/Version20151223125946.php b/Neos.Neos/Migrations/Postgresql/Version20151223125946.php index 99321c107ec..4b0d4d0ccdc 100644 --- a/Neos.Neos/Migrations/Postgresql/Version20151223125946.php +++ b/Neos.Neos/Migrations/Postgresql/Version20151223125946.php @@ -5,7 +5,6 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -use Neos\Neos\Utility\User as UserUtility; use PDO; /** @@ -34,7 +33,7 @@ public function up(Schema $schema): void $neosAccountQuery = $this->connection->executeQuery('SELECT t0.party_abstractparty, t1.accountidentifier FROM typo3_party_domain_model_abstractparty_accounts_join t0 JOIN typo3_flow_security_account t1 ON t0.flow_security_account = t1.persistence_object_identifier WHERE t1.authenticationprovidername = \'Typo3BackendProvider\''); while ($account = $neosAccountQuery->fetch(PDO::FETCH_ASSOC)) { - $normalizedUsername = UserUtility::slugifyUsername($account['accountidentifier']); + $normalizedUsername = preg_replace('/[^a-z0-9]/i', '', $account['accountidentifier']) ?: ''; foreach ($workspacesWithoutOwner as $workspaceWithoutOwner) { if ($workspaceWithoutOwner['name'] === 'user-' . $normalizedUsername) { diff --git a/Neos.Neos/Migrations/Postgresql/Version20160104121413.php b/Neos.Neos/Migrations/Postgresql/Version20160104121413.php index 16f6585f454..f531511bf00 100644 --- a/Neos.Neos/Migrations/Postgresql/Version20160104121413.php +++ b/Neos.Neos/Migrations/Postgresql/Version20160104121413.php @@ -5,7 +5,6 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -use Neos\Neos\Utility\User as UserUtility; use PDO; /** @@ -34,7 +33,7 @@ public function up(Schema $schema): void $neosAccountQuery = $this->connection->executeQuery('SELECT t0.party_abstractparty, t1.accountidentifier FROM typo3_party_domain_model_abstractparty_accounts_join t0 JOIN typo3_flow_security_account t1 ON t0.flow_security_account = t1.persistence_object_identifier WHERE t1.authenticationprovidername = \'Typo3BackendProvider\''); while ($account = $neosAccountQuery->fetch(PDO::FETCH_ASSOC)) { - $normalizedUsername = UserUtility::slugifyUsername($account['accountidentifier']); + $normalizedUsername = preg_replace('/[^a-z0-9]/i', '', $account['accountidentifier']) ?: ''; foreach ($workspacesWithoutOwner as $workspaceWithoutOwner) { if ($workspaceWithoutOwner['name'] === 'user-' . $normalizedUsername) { diff --git a/Neos.Neos/Migrations/Postgresql/Version20240425223901.php b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php new file mode 100644 index 00000000000..b370bd7b9d1 --- /dev/null +++ b/Neos.Neos/Migrations/Postgresql/Version20240425223901.php @@ -0,0 +1,47 @@ +abortIf($this->connection->getDatabasePlatform()?->getName() !== 'postgresql'); + + $tableWorkspaceMetadata = $schema->createTable('neos_neos_workspace_metadata'); + $tableWorkspaceMetadata->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceMetadata->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('title', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('description', 'text'); + $tableWorkspaceMetadata->addColumn('classification', 'string', ['length' => 255]); + $tableWorkspaceMetadata->addColumn('owner_user_id', 'string', ['length' => 255, 'notnull' => false]); + $tableWorkspaceMetadata->setPrimaryKey(['content_repository_id', 'workspace_name']); + $tableWorkspaceMetadata->addIndex(['owner_user_id']); + + $tableWorkspaceRole = $schema->createTable('neos_neos_workspace_role'); + $tableWorkspaceRole->addColumn('content_repository_id', 'string', ['length' => 16]); + $tableWorkspaceRole->addColumn('workspace_name', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('subject_type', 'string', ['length' => 20]); + $tableWorkspaceRole->addColumn('subject', 'string', ['length' => 255]); + $tableWorkspaceRole->addColumn('role', 'string', ['length' => 20]); + $tableWorkspaceRole->setPrimaryKey(['content_repository_id', 'workspace_name', 'subject_type', 'subject']); + } + + public function down(Schema $schema): void + { + $this->abortIf($this->connection->getDatabasePlatform()?->getName() !== 'postgresql'); + + $schema->dropTable('neos_neos_workspace_role'); + $schema->dropTable('neos_neos_workspace_metadata'); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php new file mode 100644 index 00000000000..fabfcd12608 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/ExceptionsTrait.php @@ -0,0 +1,59 @@ +lastCaughtException !== null) { + throw new \RuntimeException(sprintf('Can\'t execute new commands before catching previous exception: %s', $this->lastCaughtException->getMessage()), 1728464381, $this->lastCaughtException); + } + try { + return $callback(); + } catch (\Exception $exception) { + $this->lastCaughtException = $exception; + return null; + } + } + + /** + * @Then an exception :exceptionMessage should be thrown + */ + public function anExceptionShouldBeThrown(string $exceptionMessage): void + { + Assert::assertNotNull($this->lastCaughtException, 'Expected an exception but none was thrown'); + Assert::assertSame($exceptionMessage, $this->lastCaughtException->getMessage()); + $this->lastCaughtException = null; + } + + /** + * @BeforeScenario + * @AfterScenario + */ + public function afterScenarioExceptionsTrait(): void + { + if ($this->lastCaughtException !== null) { + throw new \RuntimeException(sprintf('Previous exception was not handled: %s', $this->lastCaughtException->getMessage()), 1728464379, $this->lastCaughtException); + } + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php index 5e2c525774d..71c94d72ba7 100644 --- a/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/FeatureContext.php @@ -45,6 +45,8 @@ class FeatureContext implements BehatContext use AssetUsageTrait; use AssetTrait; + use WorkspaceServiceTrait; + protected Environment $environment; protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -56,7 +58,6 @@ public function __construct() $this->environment = $this->getObject(Environment::class); $this->contentRepositoryRegistry = $this->getObject(ContentRepositoryRegistry::class); $this->persistenceManager = $this->getObject(PersistenceManagerInterface::class); - } /* diff --git a/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php new file mode 100644 index 00000000000..0570d6ee3b9 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/Bootstrap/WorkspaceServiceTrait.php @@ -0,0 +1,203 @@ + $className + * + * @return T + */ + abstract private function getObject(string $className): object; + + /** + * @When the root workspace :workspaceName is created + * @When the root workspace :workspaceName with title :title and description :description is created + */ + public function theRootWorkspaceIsCreated(string $workspaceName, string $title = null, string $description = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createRootWorkspace( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($title ?? $workspaceName), + WorkspaceDescription::fromString($description ?? ''), + )); + } + + /** + * @When the personal workspace :workspaceName is created with the target workspace :targetWorkspace for user :ownerUserId + */ + public function thePersonalWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace, string $ownerUserId): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createPersonalWorkspace( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($workspaceName), + WorkspaceDescription::fromString(''), + WorkspaceName::fromString($targetWorkspace), + UserId::fromString($ownerUserId), + )); + } + + /** + * @When the shared workspace :workspaceName is created with the target workspace :targetWorkspace + */ + public function theSharedWorkspaceIsCreatedWithTheTargetWorkspace(string $workspaceName, string $targetWorkspace): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->createSharedWorkspace( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($workspaceName), + WorkspaceDescription::fromString(''), + WorkspaceName::fromString($targetWorkspace), + )); + } + + /** + * @When a root workspace :workspaceName exists without metadata + */ + public function aRootWorkspaceExistsWithoutMetadata(string $workspaceName): void + { + $this->currentContentRepository->handle(CreateRootWorkspace::create( + WorkspaceName::fromString($workspaceName), + DeprecatedWorkspaceTitle::fromString($workspaceName), + DeprecatedWorkspaceDescription::fromString(''), + ContentStreamId::create(), + )); + } + + /** + * @When a workspace :arg1 with base workspace :arg2 exists without metadata + */ + public function aWorkspaceWithBaseWorkspaceExistsWithoutMetadata(string $workspaceName, string $baseWorkspaceName): void + { + $this->currentContentRepository->handle(CreateWorkspace::create( + WorkspaceName::fromString($workspaceName), + WorkspaceName::fromString($baseWorkspaceName), + DeprecatedWorkspaceTitle::fromString($workspaceName), + DeprecatedWorkspaceDescription::fromString(''), + ContentStreamId::create(), + )); + } + + /** + * @When the title of workspace :workspaceName is set to :newTitle + */ + public function theTitleOfWorkspaceIsSetTo(string $workspaceName, string $newTitle): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceTitle( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceTitle::fromString($newTitle), + )); + } + + /** + * @When the description of workspace :workspaceName is set to :newDescription + */ + public function theDescriptionOfWorkspaceIsSetTo(string $workspaceName, string $newDescription): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->setWorkspaceDescription( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceDescription::fromString($newDescription), + )); + } + + /** + * @Then the workspace :workspaceName should have the following metadata: + */ + public function theWorkspaceShouldHaveTheFollowingMetadata($workspaceName, TableNode $expectedMetadata): void + { + $workspaceMetadata = $this->getObject(WorkspaceService::class)->getWorkspaceMetadata($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + Assert::assertSame($expectedMetadata->getHash()[0], [ + 'Title' => $workspaceMetadata->title->value, + 'Description' => $workspaceMetadata->description->value, + 'Classification' => $workspaceMetadata->classification->value, + 'Owner user id' => $workspaceMetadata->ownerUserId?->value ?? '', + ]); + } + + /** + * @When the role :role is assigned to workspace :workspaceName for group :groupName + * @When the role :role is assigned to workspace :workspaceName for user :username + */ + public function theRoleIsAssignedToWorkspaceForGroupOrUser(string $role, string $workspaceName, string $groupName = null, string $username = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->assignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + WorkspaceRoleAssignment::create( + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + WorkspaceRole::from($role) + ) + )); + } + + /** + * @When the role for group :groupName is unassigned from workspace :workspaceName + * @When the role for user :username is unassigned from workspace :workspaceName + */ + public function theRoleIsUnassignedFromWorkspace(string $workspaceName, string $groupName = null, string $username = null): void + { + $this->tryCatchingExceptions(fn () => $this->getObject(WorkspaceService::class)->unassignWorkspaceRole( + $this->currentContentRepository->id, + WorkspaceName::fromString($workspaceName), + $groupName !== null ? WorkspaceRoleSubjectType::GROUP : WorkspaceRoleSubjectType::USER, + WorkspaceRoleSubject::fromString($groupName ?? $username), + )); + } + + /** + * @Then the workspace :workspaceName should have the following role assignments: + */ + public function theWorkspaceShouldHaveTheFollowingRoleAssignments($workspaceName, TableNode $expectedAssignments): void + { + $workspaceAssignments = $this->getObject(WorkspaceService::class)->getWorkspaceRoleAssignments($this->currentContentRepository->id, WorkspaceName::fromString($workspaceName)); + $actualAssignments = array_map(static fn (WorkspaceRoleAssignment $assignment) => [ + 'Subject type' => $assignment->subjectType->value, + 'Subject' => $assignment->subject->value, + 'Role' => $assignment->role->value, + ], iterator_to_array($workspaceAssignments)); + Assert::assertSame($expectedAssignments->getHash(), $actualAssignments); + } +} diff --git a/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature new file mode 100644 index 00000000000..3aeea41fe92 --- /dev/null +++ b/Neos.Neos/Tests/Behavior/Features/ContentRepository/WorkspaceService.feature @@ -0,0 +1,140 @@ +@flowEntities +Feature: Neos WorkspaceService related features + + Background: + Given using no content dimensions + And using the following node types: + """yaml + 'Neos.ContentRepository:Root': {} + """ + And using identifier "default", I define a content repository + And I am in content repository "default" + + Scenario: Create single root workspace without specifying title and description + When the root workspace "some-root-workspace" is created + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-root-workspace | | ROOT | | + + Scenario: Create single root workspace with title and description + When the root workspace "some-root-workspace" with title "Some root workspace" and description "Some description" is created + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | Some root workspace | Some description | ROOT | | + + Scenario: Create root workspace with a name that exceeds the workspace name max length + When the root workspace "some-name-that-exceeds-the-max-allowed-length" is created + Then an exception 'Invalid workspace name "some-name-that-exceeds-the-max-allowed-length" given. A workspace name has to consist of at most 36 lower case characters' should be thrown + + Scenario: Create root workspace with a name that is already used + Given the root workspace "some-root-workspace" is created + When the root workspace "some-root-workspace" is created + Then an exception "The workspace some-root-workspace already exists" should be thrown + + Scenario: Get metadata of non-existing root workspace + When a root workspace "some-root-workspace" exists without metadata + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-root-workspace | | ROOT | | + + Scenario: Change title of root workspace + When the root workspace "some-root-workspace" is created + And the title of workspace "some-root-workspace" is set to "Some new workspace title" + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | Some new workspace title | | ROOT | | + + Scenario: Set title of root workspace without metadata + When a root workspace "some-root-workspace" exists without metadata + And the title of workspace "some-root-workspace" is set to "Some new workspace title" + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | Some new workspace title | | ROOT | | + + Scenario: Change description of root workspace + When the root workspace "some-root-workspace" is created + And the description of workspace "some-root-workspace" is set to "Some new workspace description" + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-root-workspace | Some new workspace description | ROOT | | + + Scenario: Change description of root workspace without metadata + When a root workspace "some-root-workspace" exists without metadata + And the description of workspace "some-root-workspace" is set to "Some new workspace description" + Then the workspace "some-root-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-root-workspace | Some new workspace description | ROOT | | + + + Scenario: Create a single personal workspace + When the root workspace "some-root-workspace" is created + And the personal workspace "some-user-workspace" is created with the target workspace "some-root-workspace" for user "some-user-id" + Then the workspace "some-user-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-user-workspace | | PERSONAL | some-user-id | + + Scenario: Create a single shared workspace + When the root workspace "some-root-workspace" is created + And the shared workspace "some-shared-workspace" is created with the target workspace "some-root-workspace" + Then the workspace "some-shared-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-shared-workspace | | SHARED | | + + Scenario: Get metadata of non-existing sub workspace + Given the root workspace "some-root-workspace" is created + When a workspace "some-workspace" with base workspace "some-root-workspace" exists without metadata + Then the workspace "some-workspace" should have the following metadata: + | Title | Description | Classification | Owner user id | + | some-workspace | | UNKNOWN | | + + Scenario: Assign role to non-existing workspace + When the role COLLABORATOR is assigned to workspace "some-workspace" for group "Neos.Neos:AbstractEditor" + Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + + Scenario: Assign group role to root workspace + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | + + Scenario: Assign a role to the same group twice + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + And the role MANAGER is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "Neos.Neos:AbstractEditor" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + + Scenario: Assign user role to root workspace + Given the root workspace "some-root-workspace" is created + When the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | USER | some-user-id | MANAGER | + + Scenario: Assign a role to the same user twice + Given the root workspace "some-root-workspace" is created + When the role COLLABORATOR is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + Then an exception 'Failed to assign role for workspace "some-root-workspace" to subject "some-user-id" (Content Repository "default"): There is already a role assigned for that user/group, please unassign that first' should be thrown + + Scenario: Unassign role from non-existing workspace + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-workspace" + Then an exception 'Failed to find workspace with name "some-workspace" for content repository "default"' should be thrown + + Scenario: Unassign role from workspace that has not been assigned before + Given the root workspace "some-root-workspace" is created + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" + Then an exception 'Failed to unassign role for subject "Neos.Neos:AbstractEditor" from workspace "some-root-workspace" (Content Repository "default"): No role assignment exists for this user/group' should be thrown + + Scenario: Assign two roles, then unassign one + Given the root workspace "some-root-workspace" is created + And the role MANAGER is assigned to workspace "some-root-workspace" for user "some-user-id" + And the role COLLABORATOR is assigned to workspace "some-root-workspace" for group "Neos.Neos:AbstractEditor" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | GROUP | Neos.Neos:AbstractEditor | COLLABORATOR | + | USER | some-user-id | MANAGER | + When the role for group "Neos.Neos:AbstractEditor" is unassigned from workspace "some-root-workspace" + Then the workspace "some-root-workspace" should have the following role assignments: + | Subject type | Subject | Role | + | USER | some-user-id | MANAGER | diff --git a/Neos.Neos/Tests/Unit/Service/UserServiceTest.php b/Neos.Neos/Tests/Unit/Service/UserServiceTest.php index faeaa5fe8a0..fbbbe57c25e 100644 --- a/Neos.Neos/Tests/Unit/Service/UserServiceTest.php +++ b/Neos.Neos/Tests/Unit/Service/UserServiceTest.php @@ -118,54 +118,6 @@ public function getBackendUserReturnsTheCurrentlyLoggedInUser() self::assertSame($mockUser, $this->userService->getBackendUser()); } - /** - * @test - */ - public function getPersonalWorkspaceReturnsNullIfNoUserIsLoggedIn() - { - $this->mockUserDomainService->expects(self::atLeastOnce())->method('getCurrentUser')->will(self::returnValue(null)); - self::assertNull($this->userService->getPersonalWorkspace()); - } - - /** - * @test - */ - public function getPersonalWorkspaceReturnsTheUsersWorkspaceIfAUserIsLoggedIn() - { - $mockUser = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock(); - $mockUserWorkspace = $this->getMockBuilder(Workspace::class)->disableOriginalConstructor()->getMock(); - $mockAccount = $this->getMockBuilder(Account::class)->disableOriginalConstructor()->getMock(); - - $this->mockSecurityContext->expects(self::atLeastOnce())->method('getAccount')->will(self::returnValue($mockAccount)); - $this->mockUserDomainService->expects(self::atLeastOnce())->method('getCurrentUser')->will(self::returnValue($mockUser)); - $this->mockUserDomainService->expects(self::atLeastOnce())->method('getUserName')->with($mockUser)->will(self::returnValue('TheUserName')); - $this->mockWorkspaceRepository->expects(self::atLeastOnce())->method('findOneByName')->with('user-TheUserName')->will(self::returnValue($mockUserWorkspace)); - self::assertSame($mockUserWorkspace, $this->userService->getPersonalWorkspace()); - } - - /** - * @test - */ - public function getPersonalWorkspaceNameReturnsNullIfNoUserIsLoggedIn() - { - $this->mockUserDomainService->expects(self::atLeastOnce())->method('getCurrentUser')->will(self::returnValue(null)); - self::assertNull($this->userService->getPersonalWorkspaceName()); - } - - /** - * @test - */ - public function getPersonalWorkspaceNameReturnsTheUsersWorkspaceNameIfAUserIsLoggedIn() - { - $mockUser = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock(); - $mockAccount = $this->getMockBuilder(Account::class)->disableOriginalConstructor()->getMock(); - - $this->mockSecurityContext->expects(self::atLeastOnce())->method('getAccount')->will(self::returnValue($mockAccount)); - $this->mockUserDomainService->expects(self::atLeastOnce())->method('getCurrentUser')->will(self::returnValue($mockUser)); - $this->mockUserDomainService->expects(self::atLeastOnce())->method('getUserName')->with($mockUser)->will(self::returnValue('TheUserName')); - self::assertSame('user-TheUserName', $this->userService->getPersonalWorkspaceName()); - } - /** * @test */ diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index c2f64c39e54..c5710e14cfb 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -16,11 +16,8 @@ use Doctrine\DBAL\Exception as DBALException; use Neos\ContentRepository\Core\ContentRepository; -use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Command\CreateWorkspace; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; -use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\ChangeWorkspaceOwner; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\RenameWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; @@ -30,13 +27,11 @@ use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\Projection\Workspace\Workspace; +use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; -use Neos\ContentRepository\Core\SharedModel\User\UserId; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceDescription; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceTitle; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Diff\Diff; use Neos\Diff\Renderer\Html\HtmlArrayRenderer; @@ -44,11 +39,9 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\I18n\Exception\IndexOutOfBoundsException; use Neos\Flow\I18n\Exception\InvalidFormatPlaceholderException; -use Neos\Flow\Mvc\ActionRequest; use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; -use Neos\Flow\Security\Account; use Neos\Flow\Security\Context; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; @@ -56,19 +49,25 @@ use Neos\Neos\Controller\Module\ModuleTranslationTrait; use Neos\Neos\Domain\Model\SiteNodeName; use Neos\Neos\Domain\Model\User; +use Neos\Neos\Domain\Model\WorkspaceClassification; +use Neos\Neos\Domain\Model\WorkspaceDescription; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; +use Neos\Neos\Domain\Model\WorkspaceTitle; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\UserService; -use Neos\Neos\Domain\Service\WorkspaceNameBuilder; -use Neos\Neos\Domain\Workspace\DiscardAllChanges; -use Neos\Neos\Domain\Workspace\PublishAllChanges; -use Neos\Neos\Domain\Workspace\WorkspaceProvider; +use Neos\Neos\Domain\Service\WorkspacePublishingService; +use Neos\Neos\Domain\Service\WorkspaceService; use Neos\Neos\FrontendRouting\NodeAddress as LegacyNodeAddress; use Neos\Neos\FrontendRouting\NodeAddressFactory; use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\PendingChangesProjection\ChangeFinder; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; +use Neos\Workspace\Ui\ViewModel\PendingChanges; +use Neos\Workspace\Ui\ViewModel\WorkspaceListItem; +use Neos\Workspace\Ui\ViewModel\WorkspaceListItems; /** * The Neos Workspace module controller @@ -95,153 +94,126 @@ class WorkspaceController extends AbstractModuleController protected Context $securityContext; #[Flow\Inject] - protected UserService $domainUserService; + protected UserService $userService; #[Flow\Inject] protected PackageManager $packageManager; #[Flow\Inject] - protected WorkspaceProvider $workspaceProvider; + protected WorkspacePublishingService $workspacePublishingService; + + #[Flow\Inject] + protected WorkspaceService $workspaceService; /** * Display a list of unpublished content */ public function indexAction(): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new \RuntimeException('No user authenticated', 1718308216); + } - /** @var ?Account $currentAccount */ - $currentAccount = $this->securityContext->getAccount(); - if ($currentAccount === null) { - throw new \RuntimeException('No account is authenticated', 1710068839); + $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); + $numberOfContentRepositories = $contentRepositoryIds->count(); + if ($numberOfContentRepositories === 0) { + throw new \RuntimeException('No content repository configured', 1718296290); } - $userWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName( - WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()) - ); - if (is_null($userWorkspace)) { - throw new \RuntimeException('Current user has no workspace', 1645485990); + if ($this->request->hasArgument('contentRepositoryId')) { + $contentRepositoryIdArgument = $this->request->getArgument('contentRepositoryId'); + assert(is_string($contentRepositoryIdArgument)); + $contentRepositoryId = ContentRepositoryId::fromString($contentRepositoryIdArgument); + } else { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; } + $this->view->assign('contentRepositoryIds', $contentRepositoryIds); + $this->view->assign('contentRepositoryId', $contentRepositoryId->value); + $this->view->assign('displayContentRepositorySelector', $numberOfContentRepositories > 1); - $workspacesAndCounts = [ - $userWorkspace->workspaceName->value => [ - 'workspace' => $userWorkspace, - 'changesCounts' => $this->computeChangesCount($userWorkspace, $contentRepository), - 'canPublish' => false, - 'canManage' => false, - 'canDelete' => false, - 'workspaceOwnerHumanReadable' => $userWorkspace->workspaceOwner ? $this->domainUserService->findByUserIdentifier(UserId::fromString($userWorkspace->workspaceOwner))?->getLabel() : null - ] - ]; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $items = []; foreach ($contentRepository->getWorkspaceFinder()->findAll() as $workspace) { - /** @var \Neos\ContentRepository\Core\Projection\Workspace\Workspace $workspace */ - // FIXME: This check should be implemented through a specialized Workspace Privilege or something similar - if (!$workspace->isPersonalWorkspace() && ($workspace->isInternalWorkspace() || $this->domainUserService->currentUserCanManageWorkspace($workspace))) { - $workspaceName = $workspace->workspaceName->value; - $workspacesAndCounts[$workspaceName]['workspace'] = $workspace; - $workspacesAndCounts[$workspaceName]['changesCounts'] = - $this->computeChangesCount($workspace, $contentRepository); - $workspacesAndCounts[$workspaceName]['canPublish'] - = $this->domainUserService->currentUserCanPublishToWorkspace($workspace); - $workspacesAndCounts[$workspaceName]['canManage'] - = $this->domainUserService->currentUserCanManageWorkspace($workspace); - $workspacesAndCounts[$workspaceName]['dependentWorkspacesCount'] = count( - $contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName) - ); - $workspacesAndCounts[$workspaceName]['workspaceOwnerHumanReadable'] = $workspace->workspaceOwner ? $this->domainUserService->findByUserIdentifier(UserId::fromString($workspace->workspaceOwner))?->getLabel() : null; + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $workspace->workspaceName, $currentUser); + if (!$permissions->read) { + continue; } + $items[] = new WorkspaceListItem( + name: $workspace->workspaceName->value, + classification: $workspaceMetadata->classification->name, + title: $workspaceMetadata->title->value, + description: $workspaceMetadata->description->value, + baseWorkspaceName: $workspace->baseWorkspaceName?->value, + pendingChanges: $this->computePendingChanges($workspace, $contentRepository), + hasDependantWorkspaces: count($contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName)) > 0, + permissions: $permissions, + ); } - - $this->view->assign('userWorkspace', $userWorkspace); - $this->view->assign('workspacesAndChangeCounts', $workspacesAndCounts); + $this->view->assign('workspaces', WorkspaceListItems::fromArray($items)); } - public function showAction(WorkspaceName $workspace): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new \RuntimeException('No user authenticated', 1720371024); + } + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $workspaceControllerInternals = $this->contentRepositoryRegistry->buildService( - $contentRepositoryId, - new WorkspaceControllerInternalsFactory() - ); $workspaceObj = $contentRepository->getWorkspaceFinder()->findOneByName($workspace); if (is_null($workspaceObj)) { /** @todo add flash message */ $this->redirect('index'); } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace); + $baseWorkspaceMetadata = null; + $baseWorkspacePermissions = null; + if ($workspaceObj->baseWorkspaceName !== null) { + $baseWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceObj->baseWorkspaceName); + assert($baseWorkspace !== null); + $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); + $baseWorkspacePermissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepositoryId, $baseWorkspace->workspaceName, $currentUser); + } $this->view->assignMultiple([ 'selectedWorkspace' => $workspaceObj, - 'selectedWorkspaceLabel' => $workspaceObj->workspaceTitle, + 'selectedWorkspaceLabel' => $workspaceMetadata->title->value, 'baseWorkspaceName' => $workspaceObj->baseWorkspaceName, - 'baseWorkspaceLabel' => $workspaceObj->baseWorkspaceName, // TODO fallback to title - // TODO $this->domainUserService->currentUserCanPublishToWorkspace($workspace->getBaseWorkspace()), - 'canPublishToBaseWorkspace' => true, + 'baseWorkspaceLabel' => $baseWorkspaceMetadata?->title->value, + 'canPublishToBaseWorkspace' => $baseWorkspacePermissions?->write ?? false, 'siteChanges' => $this->computeSiteChanges($workspaceObj, $contentRepository), - 'contentDimensions' => $workspaceControllerInternals->getContentDimensionsOrderedByPriority() + 'contentDimensions' => $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority() ]); } - public function newAction(): void + public function newAction(ContentRepositoryId $contentRepositoryId): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository)); + $this->view->assign('contentRepositoryId', $contentRepositoryId->value); } - /** - * Create a workspace - * - * @Flow\Validate(argumentName="title", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") - * @param WorkspaceTitle $title Human friendly title of the workspace, for example "Christmas Campaign" - * @param WorkspaceName $baseWorkspace Workspace the new workspace should be based on - * @param string $visibility Visibility of the new workspace, must be either "internal" or "shared" - * @param WorkspaceDescription $description A description explaining the purpose of the new workspace - * @return void - * @throws \Neos\Flow\Mvc\Exception\StopActionException - */ public function createAction( + ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceName $baseWorkspace, - string $visibility, WorkspaceDescription $description, ): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - - $workspaceName = WorkspaceName::fromString( - WorkspaceName::transliterateFromString($title->value)->value . '-' - . substr(base_convert(microtime(false), 10, 36), -5, 5) - ); - while ($contentRepository->getWorkspaceFinder()->findOneByName($workspaceName) instanceof Workspace) { - $workspaceName = WorkspaceName::fromString( - WorkspaceName::transliterateFromString($title->value)->value . '-' - . substr(base_convert(microtime(false), 10, 36), -5, 5) - ); - } - - $currentUserIdentifier = $this->domainUserService->getCurrentUserIdentifier(); - if (is_null($currentUserIdentifier)) { - throw new \InvalidArgumentException('Cannot create workspace without a current user', 1652155039); + $currentUser = $this->userService->getCurrentUser(); + if ($currentUser === null) { + throw new \RuntimeException('No user authenticated', 1718303756); } - + $workspaceName = $this->workspaceService->getUniqueWorkspaceName($contentRepositoryId, $title->value); try { - $contentRepository->handle( - CreateWorkspace::create( - $workspaceName, - $baseWorkspace, - $title, - $description, - ContentStreamId::create(), - $visibility === 'private' ? $currentUserIdentifier : null - ) + $this->workspaceService->createSharedWorkspace( + $contentRepositoryId, + $workspaceName, + $title, + $description, + $baseWorkspace, ); } catch (WorkspaceAlreadyExists $exception) { $this->addFlashMessage( @@ -251,7 +223,23 @@ public function createAction( ); $this->redirect('new'); } - + $this->workspaceService->assignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleAssignment::createForUser( + $currentUser->getId(), + WorkspaceRole::MANAGER, + ) + ); + $this->workspaceService->assignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleAssignment::createForGroup( + 'Neos.Neos:AbstractEditor', + WorkspaceRole::COLLABORATOR, + ) + ); + $this->addFlashMessage($this->getModuleLabel('workspaces.workspaceHasBeenCreated', [$title->value])); $this->redirect('index'); } @@ -266,17 +254,17 @@ public function editAction(WorkspaceName $workspaceName): void $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); if (is_null($workspace)) { - // @todo add flash message + $this->addFlashMessage('Failed to find workspace "%s"', 'Error', Message::SEVERITY_ERROR, [$workspaceName->value]); $this->redirect('index'); } $this->view->assign('workspace', $workspace); - $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, $workspace)); + $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, $workspaceName)); // TODO: $this->view->assign('disableBaseWorkspaceSelector', // $this->publishingService->getUnpublishedNodesCount($workspace) > 0); - $this->view->assign( - 'showOwnerSelector', - $this->domainUserService->currentUserCanTransferOwnershipOfWorkspace($workspace) - ); + + // TODO fix $this->userService->currentUserCanTransferOwnershipOfWorkspace($workspace) + $this->view->assign('showOwnerSelector', false); + $this->view->assign('ownerOptions', $this->prepareOwnerOptions()); } @@ -287,17 +275,14 @@ public function editAction(WorkspaceName $workspaceName): void * @param WorkspaceName $workspaceName * @param WorkspaceTitle $title Human friendly title of the workspace, for example "Christmas Campaign" * @param WorkspaceDescription $description A description explaining the purpose of the new workspace - * @param string $workspaceOwner Id of the owner of the workspace * @return void */ public function updateAction( WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, - ?string $workspaceOwner ): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); if ($title->value === '') { @@ -313,26 +298,16 @@ public function updateAction( ); $this->redirect('index'); } - - if (!$workspace->workspaceTitle->equals($title) || !$workspace->workspaceDescription->equals($description)) { - $contentRepository->handle( - RenameWorkspace::create( - $workspaceName, - $title, - $description - ) - ); - } - - if ($workspace->workspaceOwner !== $workspaceOwner) { - $contentRepository->handle( - ChangeWorkspaceOwner::create( - $workspaceName, - $workspaceOwner ?: null, - ) - ); - } - + $this->workspaceService->setWorkspaceTitle( + $contentRepositoryId, + $workspaceName, + $title, + ); + $this->workspaceService->setWorkspaceDescription( + $contentRepositoryId, + $workspaceName, + $description, + ); $this->addFlashMessage($this->translator->translateById( 'workspaces.workspaceHasBeenUpdated', [$title->value], @@ -355,8 +330,7 @@ public function updateAction( */ public function deleteAction(WorkspaceName $workspaceName): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($workspaceName); @@ -369,22 +343,23 @@ public function deleteAction(WorkspaceName $workspaceName): void $this->redirect('index'); } - if ($workspace->isPersonalWorkspace()) { + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + + if ($workspaceMetadata->classification === WorkspaceClassification::PERSONAL) { $this->redirect('index'); } - $dependentWorkspaces = $contentRepository->getWorkspaceFinder() - ->findByBaseWorkspace($workspace->workspaceName); + $dependentWorkspaces = $contentRepository->getWorkspaceFinder()->findByBaseWorkspace($workspace->workspaceName); if (count($dependentWorkspaces) > 0) { $dependentWorkspaceTitles = []; - /** @var Workspace $dependentWorkspace */ foreach ($dependentWorkspaces as $dependentWorkspace) { - $dependentWorkspaceTitles[] = $dependentWorkspace->workspaceTitle->value; + $dependentWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $dependentWorkspace->workspaceName); + $dependentWorkspaceTitles[] = $dependentWorkspaceMetadata->title->value; } $message = $this->translator->translateById( 'workspaces.workspaceCannotBeDeletedBecauseOfDependencies', - [$workspace->workspaceTitle->value, implode(', ', $dependentWorkspaceTitles)], + [$workspaceMetadata->title->value, implode(', ', $dependentWorkspaceTitles)], null, null, 'Main', @@ -404,7 +379,7 @@ public function deleteAction(WorkspaceName $workspaceName): void } catch (\Exception $exception) { $message = $this->translator->translateById( 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes', - [$workspace->workspaceTitle->value], + [$workspaceMetadata->title->value], null, null, 'Main', @@ -416,7 +391,7 @@ public function deleteAction(WorkspaceName $workspaceName): void if ($nodesCount > 0) { $message = $this->translator->translateById( 'workspaces.workspaceCannotBeDeletedBecauseOfUnpublishedNodes', - [$workspace->workspaceTitle->value, $nodesCount], + [$workspaceMetadata->title->value, $nodesCount], $nodesCount, null, 'Main', @@ -434,7 +409,7 @@ public function deleteAction(WorkspaceName $workspaceName): void $this->addFlashMessage($this->translator->translateById( 'workspaces.workspaceHasBeenRemoved', - [$workspace->workspaceTitle->value], + [$workspaceMetadata->title->value], null, null, 'Main', @@ -455,14 +430,11 @@ public function rebaseAndRedirectAction(string $targetNode, Workspace $targetWor // todo legacy uri node address notation used. Should be refactored to use json encoded NodeAddress $targetNodeAddress = NodeAddressFactory::create($contentRepository)->createCoreNodeAddressFromLegacyUriString($targetNode); - /** @var ?Account $currentAccount */ - $currentAccount = $this->securityContext->getAccount(); - if ($currentAccount === null) { + $user = $this->userService->getCurrentUser(); + if ($user === null) { throw new \RuntimeException('No account is authenticated', 1710068880); } - $personalWorkspaceName = WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()); - /** @var Workspace $personalWorkspace */ - $personalWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName($personalWorkspaceName); + $personalWorkspace = $this->workspaceService->getPersonalWorkspaceForUser($contentRepositoryId, $user->getId()); /** @todo do something else * if ($personalWorkspace !== $targetWorkspace) { @@ -499,7 +471,6 @@ public function rebaseAndRedirectAction(string $targetNode, Workspace $targetWor $targetNodeAddressInPersonalWorkspace->workspaceName ); $mainRequest = $this->controllerContext->getRequest()->getMainRequest(); - /** @var ActionRequest $mainRequest */ $this->uriBuilder->setRequest($mainRequest); $this->redirect( @@ -661,26 +632,16 @@ public function publishOrDiscardNodesAction(array $nodes, string $action, string */ public function publishWorkspaceAction(WorkspaceName $workspace): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - /** @todo send from UI */ - $command = new PublishAllChanges( + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $publishingResult = $this->workspacePublishingService->publishWorkspace( $contentRepositoryId, - $workspace + $workspace, ); - - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $command->contentRepositoryId, - $command->workspaceName - ); - $workspace->publishAllChanges(); - /** @var WorkspaceName $baseWorkspaceName Otherwise the command handler would have thrown an exception */ - $baseWorkspaceName = $workspace->getCurrentBaseWorkspaceName(); $this->addFlashMessage($this->translator->translateById( 'workspaces.allChangesInWorkspaceHaveBeenPublished', [ - htmlspecialchars($workspace->name->value), - htmlspecialchars($baseWorkspaceName->value) + htmlspecialchars($workspace->value), + htmlspecialchars($publishingResult->targetWorkspaceName->value) ], null, null, @@ -697,22 +658,14 @@ public function publishWorkspaceAction(WorkspaceName $workspace): void */ public function discardWorkspaceAction(WorkspaceName $workspace): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) - ->contentRepositoryId; - /** @todo send from UI */ - $command = new DiscardAllChanges( + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $this->workspacePublishingService->discardAllWorkspaceChanges( $contentRepositoryId, - $workspace - ); - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $command->contentRepositoryId, - $command->workspaceName + $workspace, ); - $workspace->discardAllChanges(); - $this->addFlashMessage($this->translator->translateById( 'workspaces.allChangesInWorkspaceHaveBeenDiscarded', - [htmlspecialchars($workspace->name->value)], + [htmlspecialchars($workspace->value)], null, null, 'Main', @@ -723,12 +676,10 @@ public function discardWorkspaceAction(WorkspaceName $workspace): void /** * Computes the number of added, changed and removed nodes for the given workspace - * - * @return array */ - protected function computeChangesCount(Workspace $selectedWorkspace, ContentRepository $contentRepository): array + protected function computePendingChanges(Workspace $selectedWorkspace, ContentRepository $contentRepository): PendingChanges { - $changesCount = ['new' => 0, 'changed' => 0, 'removed' => 0, 'total' => 0]; + $changesCount = ['new' => 0, 'changed' => 0, 'removed' => 0]; foreach ($this->computeSiteChanges($selectedWorkspace, $contentRepository) as $siteChanges) { foreach ($siteChanges['documents'] as $documentChanges) { foreach ($documentChanges['changes'] as $change) { @@ -739,12 +690,10 @@ protected function computeChangesCount(Workspace $selectedWorkspace, ContentRepo } else { $changesCount['changed']++; } - $changesCount['total']++; } } } - - return $changesCount; + return new PendingChanges(new: $changesCount['new'], changed: $changesCount['changed'], removed: $changesCount['removed']); } /** @@ -1073,31 +1022,40 @@ protected function postProcessDiffArray(array &$diffArray): void /** * Creates an array of workspace names and their respective titles which are possible base workspaces for other * workspaces. - * If $excludedWorkspace is set, this workspace and all its child workspaces will be excluded from the list of returned workspaces + * If $excludedWorkspace is set, this workspace and all its base workspaces will be excluded from the list of returned workspaces * * @param ContentRepository $contentRepository - * @param Workspace|null $excludedWorkspace + * @param WorkspaceName|null $excludedWorkspace * @return array */ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, - Workspace $excludedWorkspace = null, + WorkspaceName $excludedWorkspace = null, ): array { + $user = $this->userService->getCurrentUser(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->getWorkspaceFinder()->findAll(); foreach ($workspaces as $workspace) { - - /** @var Workspace $workspace */ - if ( - !$workspace->isPersonalWorkspace() - && $workspace !== $excludedWorkspace - && ($workspace->isPublicWorkspace() - || $workspace->isInternalWorkspace() - || $this->domainUserService->currentUserCanManageWorkspace($workspace)) - && (!$excludedWorkspace || $workspaces->getBaseWorkspaces($workspace->workspaceName)->get($excludedWorkspace->workspaceName) === null) - ) { - $baseWorkspaceOptions[$workspace->workspaceName->value] = $workspace->workspaceTitle->value; + if ($excludedWorkspace !== null) { + if ($workspace->workspaceName->equals($excludedWorkspace)) { + continue; + } + if ($workspaces->getBaseWorkspaces($workspace->workspaceName)->get($excludedWorkspace) !== null) { + continue; + } + } + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepository->id, $workspace->workspaceName); + if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { + continue; + } + if ($user === null) { + continue; + } + $permissions = $this->workspaceService->getWorkspacePermissionsForUser($contentRepository->id, $workspace->workspaceName, $user); + if (!$permissions->manage) { + continue; } + $baseWorkspaceOptions[$workspace->workspaceName->value] = $workspace->workspaceTitle->value; } return $baseWorkspaceOptions; @@ -1111,7 +1069,7 @@ protected function prepareBaseWorkspaceOptions( protected function prepareOwnerOptions(): array { $ownerOptions = ['' => '-']; - foreach ($this->domainUserService->getUsers() as $user) { + foreach ($this->userService->getUsers() as $user) { /** @var User $user */ $ownerOptions[$this->persistenceManager->getIdentifierByObject($user)] = $user->getLabel(); } diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php deleted file mode 100644 index 0a28cfe7413..00000000000 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternals.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ - public function getContentDimensionsOrderedByPriority(): array - { - return $this->contentDimensionSource->getContentDimensionsOrderedByPriority(); - } -} diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php deleted file mode 100644 index 0ad2b7f3d38..00000000000 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceControllerInternalsFactory.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -class WorkspaceControllerInternalsFactory implements ContentRepositoryServiceFactoryInterface -{ - public function build( - ContentRepositoryServiceFactoryDependencies $serviceFactoryDependencies - ): WorkspaceControllerInternals { - return new WorkspaceControllerInternals( - $serviceFactoryDependencies->contentDimensionSource, - ); - } -} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php b/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php new file mode 100644 index 00000000000..ad999538841 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php @@ -0,0 +1,46 @@ +total = $this->new + $this->changed + $this->removed; + } + + public function getNewCountRatio(): float + { + return $this->new / $this->total * 100; + } + + public function getChangedCountRatio(): float + { + return $this->changed / $this->total * 100; + } + + public function getRemovedCountRatio(): float + { + return $this->removed / $this->total * 100; + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php new file mode 100644 index 00000000000..b7c54aa3d6e --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php @@ -0,0 +1,34 @@ + + */ +#[Flow\Proxy(false)] +final readonly class WorkspaceListItems implements \IteratorAggregate, \Countable +{ + /** + * @param array $items + */ + private function __construct( + private array $items, + ) { + } + + /** + * @param array $items + */ + public static function fromArray(array $items): self + { + foreach ($items as $item) { + if (!$item instanceof WorkspaceListItem) { + throw new \InvalidArgumentException(sprintf('Expected instance of %s, got: %s', WorkspaceListItem::class, get_debug_type($item)), 1718295710); + } + } + return new self($items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html index d65d207cbd7..b76c5a2a837 100644 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html +++ b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html @@ -3,6 +3,17 @@
+ + +
+ +
+ +
+
+
+
+ @@ -14,146 +25,137 @@ - - - - + + + + + - - + - - - - - +
+ + + + + + + + + + +
 
- - - - - - + +
+ + + + + + + + + + + + + + + + + + {workspace.title -> f:format.crop(maxCharacters: 25, append: '…')} + + + {workspace.baseWorkspaceName -> f:format.crop(maxCharacters: 25, append: '…')} + + + - + + + + + + TODO + + + TODO + + + + + +
+ - - - - - - - - + - + - +
+
+ + + + {neos:backend.translate(id: 'workspaces.review', source: 'Main', package: 'Neos.Workspace.Ui')} + -
{workspace.workspaceTitle.value -> f:format.crop(maxCharacters: 25, append: '…')} - - - {workspace.baseWorkspaceName.value -> f:format.crop(maxCharacters: 25, append: '…')} - - - - - + + +
+ + + + -
- + + - {workspaceAndCounts.workspaceOwnerHumanReadable} + - {workspaceAndCounts.workspaceOwnerHumanReadable} - - - - - -
- - - - - - - - - - -
-
- - - - {neos:backend.translate(id: 'workspaces.review', source: 'Main', package: 'Neos.Workspace.Ui')} - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
{neos:backend.translate(id: 'workspaces.dialog.confirmWorkspaceDeletion', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: workspace.workspaceTitle.value})}
-
-
-

{neos:backend.translate(id: 'workspaces.dialog.thisWillDeleteTheWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}

-
+ + + + + + + + + + + + +
+
+
+
+ +
{neos:backend.translate(id: 'workspaces.dialog.confirmWorkspaceDeletion', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: workspace.workspaceTitle.value})}
+
+
+

{neos:backend.translate(id: 'workspaces.dialog.thisWillDeleteTheWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}

- +
+
-
- - - - - - - -
-
diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html index 6c4e619e1b4..65ad3753f05 100644 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html +++ b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html @@ -6,12 +6,12 @@

{neos:backend.translate(id: 'workspaces.createNewWorkspace', source: 'Main', - +
- +
@@ -27,36 +27,13 @@

{neos:backend.translate(id: 'workspaces.createNewWorkspace', source: 'Main',
- +
- - - -
- -
- -
-
- -
-
-
- - - -

diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf index 8786d9093b1..da39fa6b61e 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf @@ -159,6 +159,9 @@ A workspace with this title already exists. + + The workspace "{0}" has been created. + The workspace "{0}" has been updated.