├── FUNDING.yml ├── Resources └── Private │ ├── Fusion │ ├── Root.fusion │ ├── Components │ │ └── FlashMessages.fusion │ └── Views │ │ └── Index.fusion │ └── Translations │ ├── en │ └── Main.xlf │ └── de │ └── Main.xlf ├── Documentation ├── Overview.png ├── CreateDialog.png ├── DeleteDialog.png ├── EditDialog.png └── SharedWorkspace.png ├── .gitignore ├── Configuration ├── Settings.WorkspaceModule.yaml ├── Settings.Flow.yaml ├── Settings.Neos.yaml ├── Views.yaml └── Policy.yaml ├── cypress.config.ts ├── .gitattributes ├── .editorconfig ├── composer.json ├── Migrations ├── Postgresql │ ├── Version20220420074047.php │ ├── Version20220412145046.php │ ├── Version20220420074419.php │ ├── Version20240108102821.php │ └── Version20230117134142.php └── Mysql │ ├── Version20220426091118.php │ ├── Version20240108102400.php │ └── Version20220615074317.php ├── LICENSE.txt ├── Classes ├── Package.php ├── WorkspaceDetailsContext.php ├── Domain │ ├── Repository │ │ └── WorkspaceDetailsRepository.php │ └── Model │ │ └── WorkspaceDetails.php ├── Service │ └── WorkspaceActivityService.php ├── Aspect │ └── SharedWorkspaceAccessAspect.php └── Controller │ └── WorkspacesController.php └── Readme.md /FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: shelzle 2 | github: sebobo 3 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Root.fusion: -------------------------------------------------------------------------------- 1 | include: **/* 2 | -------------------------------------------------------------------------------- /Documentation/Overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebobo/Shel.Neos.WorkspaceModule/HEAD/Documentation/Overview.png -------------------------------------------------------------------------------- /Documentation/CreateDialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebobo/Shel.Neos.WorkspaceModule/HEAD/Documentation/CreateDialog.png -------------------------------------------------------------------------------- /Documentation/DeleteDialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebobo/Shel.Neos.WorkspaceModule/HEAD/Documentation/DeleteDialog.png -------------------------------------------------------------------------------- /Documentation/EditDialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebobo/Shel.Neos.WorkspaceModule/HEAD/Documentation/EditDialog.png -------------------------------------------------------------------------------- /Documentation/SharedWorkspace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sebobo/Shel.Neos.WorkspaceModule/HEAD/Documentation/SharedWorkspace.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .parcel-cache 3 | .yarn/* 4 | !.yarn/cache 5 | !.yarn/patches 6 | !.yarn/plugins 7 | !.yarn/releases 8 | !.yarn/sdks 9 | !.yarn/versions 10 | Tests/dev-server/dist 11 | /cypress 12 | -------------------------------------------------------------------------------- /Configuration/Settings.WorkspaceModule.yaml: -------------------------------------------------------------------------------- 1 | Shel: 2 | Neos: 3 | WorkspaceModule: 4 | staleTime: 2419200 # 4 weeks in seconds 5 | validation: 6 | titlePattern: '^[A-Z][\s\w\d\-\.\[\]\(\)_]+\b(?!\s)$' 7 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | return require('./cypress/plugins/index.js')(on, config); 7 | }, 8 | specPattern: 'Tests/integration/**/*.cy.{js,jsx,ts,tsx}', 9 | baseUrl: 'http://localhost:4000', 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.map binary 2 | main.js binary 3 | main.css binary 4 | 5 | /.yarn export-ignore 6 | /.github export-ignore 7 | /Resources/Private/JavaScript export-ignore 8 | /Tests export-ignore 9 | /cypress 10 | 11 | /.eslintignore export-ignore 12 | /.eslintrc.js export-ignore 13 | /.nvmrc export-ignore 14 | /.prettierrc export-ignore 15 | /.yarnrc.yml export-ignore 16 | /package.json export-ignore 17 | /tsconfig.json export-ignore 18 | /yarn.lock export-ignore 19 | /cypress.json export-ignore 20 | -------------------------------------------------------------------------------- /Configuration/Settings.Flow.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Flow: 3 | security: 4 | authentication: 5 | providers: 6 | Neos.Neos:Backend: 7 | requestPatterns: 8 | Shel.Neos.WorkspaceModule:Controller: 9 | pattern: 'ControllerObjectName' 10 | patternOptions: 11 | controllerObjectNamePattern: 'Shel\Neos\WorkspaceModule\Controller\.*' 12 | 13 | aop: 14 | globalObjects: 15 | workspaceDetails: Shel\Neos\WorkspaceModule\WorkspaceDetailsContext 16 | -------------------------------------------------------------------------------- /Configuration/Settings.Neos.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Neos: 3 | modules: 4 | management: 5 | submodules: 6 | workspaces: 7 | controller: Shel\Neos\WorkspaceModule\Controller\WorkspacesController 8 | additionalResources: 9 | javaScripts: 10 | Shel.Neos.WorkspaceModule: 'resource://Shel.Neos.WorkspaceModule/Public/Assets/main.js' 11 | 12 | userInterface: 13 | translation: 14 | autoInclude: 15 | Shel.Neos.WorkspaceModule: 16 | - 'Main' 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.{yml,yaml,json}] 10 | indent_size = 2 11 | 12 | [*.md] 13 | indent_size = 2 14 | trim_trailing_whitespace = false 15 | 16 | [*.{ts,tsx}] 17 | ij_typescript_spaces_within_object_literal_braces = true 18 | ij_typescript_spaces_within_imports = true 19 | ij_html_space_inside_empty_tag = true 20 | ij_javascript_spaces_within_imports = true 21 | ij_typescript_use_double_quotes = false 22 | -------------------------------------------------------------------------------- /Configuration/Views.yaml: -------------------------------------------------------------------------------- 1 | - requestFilter: 'isPackage("Shel.Neos.WorkspaceModule") && isController("Workspaces") && isFormat("html")' 2 | options: 3 | templateRootPathPattern: 'resource://Neos.Neos/Private/Templates/Module/Management' 4 | layoutRootPathPattern: 'resource://Neos.Neos/Private/Layouts' 5 | partialRootPathPattern: 'resource://Neos.Neos/Private/Partials' 6 | 7 | - requestFilter: 'isPackage("Shel.Neos.WorkspaceModule") && isController("Workspaces") && isFormat("html") && isAction("index")' 8 | viewObjectName: 'Neos\Fusion\View\FusionView' 9 | options: 10 | fusionPathPatterns: 11 | - 'resource://Neos.Fusion/Private/Fusion' 12 | - 'resource://Shel.Neos.WorkspaceModule/Private/Fusion' 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shel/neos-workspace-module", 3 | "description": "An alternative workspace module with additional features for Neos CMS", 4 | "type": "neos-plugin", 5 | "license": "MIT", 6 | "keywords": [ 7 | "flow", 8 | "neoscms", 9 | "workspaces" 10 | ], 11 | "require": { 12 | "php": ">=7.4", 13 | "neos/neos": "^5.3 || ^7.0 || ^8.0 || dev-master" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Shel\\Neos\\WorkspaceModule\\": "Classes" 18 | } 19 | }, 20 | "extra": { 21 | "neos": { 22 | "package-key": "Shel.Neos.WorkspaceModule" 23 | } 24 | }, 25 | "archive": { 26 | "exclude": [ 27 | "/.yarn", 28 | "/.github", 29 | "/Tests" 30 | ] 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20220420074047.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 20 | 21 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD creator VARCHAR(255) NOT NULL'); 22 | } 23 | 24 | public function down(Schema $schema): void 25 | { 26 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 27 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP creator'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Sebastian Helzle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Classes/Package.php: -------------------------------------------------------------------------------- 1 | getSignalSlotDispatcher(); 26 | 27 | $dispatcher->connect( 28 | PublishingService::class, 29 | 'nodePublished', 30 | WorkspaceActivityService::class, 31 | 'nodePublished' 32 | ); 33 | 34 | $dispatcher->connect( 35 | PublishingService::class, 36 | 'nodeDiscarded', 37 | WorkspaceActivityService::class, 38 | 'nodeDiscarded' 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20220412145046.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 20 | 21 | $this->addSql('CREATE TABLE shel_neos_workspacemodule_domain_model_workspacedetails (persistence_object_identifier VARCHAR(40) NOT NULL, lastchangeddate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, lastchangedby VARCHAR(255) NOT NULL, workspacename VARCHAR(255) NOT NULL, PRIMARY KEY(persistence_object_identifier))'); 22 | } 23 | 24 | public function down(Schema $schema): void 25 | { 26 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 27 | 28 | $this->addSql('DROP TABLE shel_neos_workspacemodule_domain_model_workspacedetails'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20220426091118.php: -------------------------------------------------------------------------------- 1 | abortIf( 21 | !$this->connection->getDatabasePlatform() instanceof MySqlPlatform, 22 | "Migration can only be executed safely on 'MySqlPlatform'." 23 | ); 24 | 25 | $this->addSql('CREATE TABLE shel_neos_workspacemodule_domain_model_workspacedetails (persistence_object_identifier VARCHAR(40) NOT NULL, lastchangeddate DATETIME DEFAULT NULL, lastchangedby VARCHAR(255) DEFAULT NULL, workspacename VARCHAR(255) NOT NULL, creator VARCHAR(255) DEFAULT NULL, PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 26 | } 27 | 28 | public function down(Schema $schema): void 29 | { 30 | $this->abortIf( 31 | !$this->connection->getDatabasePlatform() instanceof MySqlPlatform, 32 | "Migration can only be executed safely on 'MySqlPlatform'." 33 | ); 34 | 35 | $this->addSql('DROP TABLE shel_neos_workspacemodule_domain_model_workspacedetails'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Components/FlashMessages.fusion: -------------------------------------------------------------------------------- 1 | prototype(Shel.Neos.WorkspaceModule:Component.FlashMessages) < prototype(Neos.Fusion:Component) { 2 | flashMessages = ${[]} 3 | 4 | renderer = afx` 5 |
6 | 7 | 8 | 9 |
10 | ` 11 | } 12 | 13 | prototype(Shel.Neos.WorkspaceModule:Component.FlashMessages.Message) < prototype(Neos.Fusion:Component) { 14 | message = ${{}} 15 | 16 | severity = ${String.toLowerCase(this.message.severity)} 17 | severity.@process.replaceOKStatus = ${value == 'ok' ? 'success' : value} 18 | severity.@process.replaceNoticeStatus = ${value == 'notice' ? 'info' : value} 19 | 20 | renderer = afx` 21 |
22 |
23 | 24 |
25 | {props.message.title || props.message.message} 26 |
27 |
{props.message.message}
28 |
29 |
30 | ` 31 | } 32 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20240108102400.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 20 | 21 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP FOREIGN KEY FK_923CCEA8D940019'); 22 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD CONSTRAINT FK_923CCEA8D940019 FOREIGN KEY (workspace) REFERENCES neos_contentrepository_domain_model_workspace (name) ON DELETE CASCADE'); 23 | } 24 | 25 | public function down(Schema $schema): void 26 | { 27 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 28 | 29 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP FOREIGN KEY FK_923CCEA8D940019'); 30 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD CONSTRAINT FK_923CCEA8D940019 FOREIGN KEY (workspace) REFERENCES neos_contentrepository_domain_model_workspace (name)'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20220420074419.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 20 | 21 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER lastchangeddate DROP NOT NULL'); 22 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER lastchangedby DROP NOT NULL'); 23 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER creator DROP NOT NULL'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 29 | 30 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER lastchangeddate SET NOT NULL'); 31 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER lastchangedby SET NOT NULL'); 32 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER creator SET NOT NULL'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20240108102821.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 20 | 21 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP CONSTRAINT FK_923CCEA8D940019'); 22 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER workspace DROP NOT NULL'); 23 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD CONSTRAINT FK_923CCEA8D940019 FOREIGN KEY (workspace) REFERENCES neos_contentrepository_domain_model_workspace (name) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); 24 | } 25 | 26 | public function down(Schema $schema): void 27 | { 28 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 29 | 30 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP CONSTRAINT fk_923ccea8d940019'); 31 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ALTER workspace SET NOT NULL'); 32 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD CONSTRAINT fk_923ccea8d940019 FOREIGN KEY (workspace) REFERENCES neos_contentrepository_domain_model_workspace (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Classes/WorkspaceDetailsContext.php: -------------------------------------------------------------------------------- 1 | userDomainService) { 58 | return []; 59 | } 60 | 61 | $user = $this->userDomainService->getCurrentUser(); 62 | 63 | if (!$user) { 64 | return []; 65 | } 66 | 67 | return $this->workspaceDetailsRepository->findAllowedWorkspaceNamesForUser($user); 68 | } 69 | 70 | public function getCacheEntryIdentifier(): string 71 | { 72 | if ($this->cacheEntryIdentifier === null) { 73 | $this->cacheEntryIdentifier = implode('_', $this->getSharedWorkspaces()); 74 | } 75 | return $this->cacheEntryIdentifier; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Classes/Domain/Repository/WorkspaceDetailsRepository.php: -------------------------------------------------------------------------------- 1 | findAll() 29 | * 30 | * @Flow\Scope("singleton") 31 | */ 32 | class WorkspaceDetailsRepository extends Repository 33 | { 34 | /** 35 | * @Flow\Inject 36 | * @var EntityManagerInterface 37 | */ 38 | protected $entityManager; 39 | 40 | /** 41 | * @return string[] The names of all workspaces shared with the given user 42 | */ 43 | public function findAllowedWorkspaceNamesForUser(User $user): array 44 | { 45 | // Prepare raw query 46 | $rsm = new ResultSetMapping(); 47 | $rsm->addScalarResult('workspace', 'workspace'); 48 | 49 | // Find all workspaces shared with the given user with one query 50 | $queryString = ' 51 | SELECT d.workspace FROM shel_neos_workspacemodule_domain_model_workspacedetails d 52 | JOIN shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join a 53 | ON d.persistence_object_identifier = a.workspacemodule_workspacedetails 54 | WHERE a.neos_user = ? 55 | '; 56 | $query = $this->entityManager->createNativeQuery($queryString, $rsm); 57 | $query->setParameter(1, $user); 58 | return $query->getSingleColumnResult(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Configuration/Policy.yaml: -------------------------------------------------------------------------------- 1 | privilegeTargets: 2 | 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': 3 | 4 | 'Shel.Neos.WorkspaceModule:Backend.WorkspaceController': 5 | label: Allowed to access the workspace module 6 | matcher: 'method(Neos\Neos\Controller\Module\Management\WorkspacesController->(index|show|update|discardNode|publishNode|rebaseAndRedirect)Action())' 7 | 8 | 'Shel.Neos.WorkspaceModule:Backend.PublishAllToLiveWorkspace': 9 | label: Allowed to publish all changes to the live workspace 10 | matcher: 'method(Shel\Neos\WorkspaceModule\Controller\WorkspacesController->publishWorkspaceAction(workspace.baseWorkspace.name === "live"))' 11 | 12 | 'Shel.Neos.WorkspaceModule:Backend.CreateWorkspaces': 13 | label: Allowed to create new workspaces 14 | matcher: 'method(Neos\Neos\Controller\Service\WorkspacesController->(new|create)Action()) || method(Shel\Neos\WorkspaceModule\Controller\WorkspacesController->(create|new)Action())' 15 | 16 | 'Shel.Neos.WorkspaceModule:Backend.SharedWorkspaceAccess': 17 | label: Access to shared workspaces which don't include current user 18 | matcher: 'method(Neos\ContentRepository\Domain\Service\Context->validateWorkspace()) && evaluate(this.workspace.owner !== current.userInformation.backendUser, this.workspace.personalWorkspace === false, current.workspaceDetails.sharedWorkspaces contains this.workspace.name)' 19 | 20 | 'Shel.Neos.WorkspaceModule:Backend.ManageSharedWorkspaces': 21 | label: Allowed to manage shared workspaces 22 | matcher: 'method(Shel\Neos\WorkspaceModule\Controller\WorkspacesController->(show|publishWorkspace|discardWorkspace|publishOrDiscardNodes|edit|update|delete)Action()) && evaluate(this.workspace.owner !== current.userInformation.backendUser, this.workspace.personalWorkspace === false, current.workspaceDetails.sharedWorkspaces contains this.workspace.name)' 23 | 24 | 'Shel.Neos.WorkspaceModule:Backend.PublishOrDiscardSharedWorkspaces': 25 | label: Allowed to discard shared workspaces 26 | matcher: 'method(Neos\Neos\Controller\Module\Management\WorkspacesController->(publish|discard)WorkspaceAction(workspace.name in current.workspaceDetails.sharedWorkspaces))' 27 | 28 | roles: 29 | 'Neos.Neos:LivePublisher': 30 | privileges: 31 | - privilegeTarget: 'Shel.Neos.WorkspaceModule:Backend.PublishAllToLiveWorkspace' 32 | permission: GRANT 33 | 34 | 'Neos.Neos:AbstractEditor': 35 | privileges: 36 | - privilegeTarget: 'Shel.Neos.WorkspaceModule:Backend.CreateWorkspaces' 37 | permission: GRANT 38 | 39 | - privilegeTarget: 'Shel.Neos.WorkspaceModule:Backend.WorkspaceController' 40 | permission: GRANT 41 | 42 | - privilegeTarget: 'Shel.Neos.WorkspaceModule:Backend.ManageSharedWorkspaces' 43 | permission: GRANT 44 | 45 | - privilegeTarget: 'Shel.Neos.WorkspaceModule:Backend.PublishOrDiscardSharedWorkspaces' 46 | permission: GRANT 47 | 48 | 'Neos.Neos:UserManager': 49 | privileges: 50 | - privilegeTarget: 'Shel.Neos.WorkspaceModule:Backend.WorkspaceController' 51 | permission: GRANT 52 | 53 | -------------------------------------------------------------------------------- /Classes/Service/WorkspaceActivityService.php: -------------------------------------------------------------------------------- 1 | 45 | */ 46 | protected $updatedWorkspaces = []; 47 | 48 | /** 49 | * @Flow\Inject 50 | * @var LoggerInterface 51 | */ 52 | protected $systemLogger; 53 | 54 | /** 55 | * @Flow\Inject 56 | * @var PersistenceManagerInterface 57 | */ 58 | protected $persistenceManager; 59 | 60 | /** 61 | * @Flow\Inject 62 | * @var WorkspaceRepository 63 | */ 64 | protected $workspaceRepository; 65 | 66 | public function nodePublished(NodeInterface $node, Workspace $targetWorkspace = null): void 67 | { 68 | if (!$targetWorkspace) { 69 | return; 70 | } 71 | $this->updatedWorkspaces[$targetWorkspace->getName()] = $targetWorkspace; 72 | } 73 | 74 | public function nodeDiscarded(NodeInterface $node): void 75 | { 76 | $this->updatedWorkspaces[$node->getWorkspace()->getName()] = $node->getWorkspace(); 77 | } 78 | 79 | public function shutdownObject(): void 80 | { 81 | $currentUser = $this->securityContext->getAccount()->getAccountIdentifier(); 82 | 83 | foreach ($this->updatedWorkspaces as $updatedWorkspace) { 84 | $workspaceDetails = $this->workspaceDetailsRepository->findOneByWorkspace($updatedWorkspace); 85 | 86 | if ($workspaceDetails) { 87 | $workspaceDetails->setLastChangedDate(new \DateTime()); 88 | $workspaceDetails->setLastChangedBy($currentUser); 89 | $this->workspaceDetailsRepository->update($workspaceDetails); 90 | } else { 91 | $workspaceDetails = new WorkspaceDetails($updatedWorkspace, null, new \DateTime(), $currentUser); 92 | $this->workspaceDetailsRepository->add($workspaceDetails); 93 | } 94 | } 95 | 96 | $this->persistenceManager->persistAll(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20220615074317.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 20 | 21 | // Remove all disconnected workspace details entities or the foreign table constraint will fail 22 | $this->addSql('DELETE FROM shel_neos_workspacemodule_domain_model_workspacedetails WHERE workspacename NOT IN (SELECT name FROM neos_contentrepository_domain_model_workspace)'); 23 | 24 | $this->addSql('CREATE TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join (workspacemodule_workspacedetails VARCHAR(40) NOT NULL, neos_user VARCHAR(40) NOT NULL, INDEX IDX_9EE667F39AF30FF3 (workspacemodule_workspacedetails), INDEX IDX_9EE667F3C7FF26B (neos_user), PRIMARY KEY(workspacemodule_workspacedetails, neos_user)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 25 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join ADD CONSTRAINT FK_9EE667F39AF30FF3 FOREIGN KEY (workspacemodule_workspacedetails) REFERENCES shel_neos_workspacemodule_domain_model_workspacedetails (persistence_object_identifier)'); 26 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join ADD CONSTRAINT FK_9EE667F3C7FF26B FOREIGN KEY (neos_user) REFERENCES neos_neos_domain_model_user (persistence_object_identifier)'); 27 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD workspace VARCHAR(255) DEFAULT NULL, DROP workspacename'); 28 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD CONSTRAINT FK_923CCEA8D940019 FOREIGN KEY (workspace) REFERENCES neos_contentrepository_domain_model_workspace (name)'); 29 | $this->addSql('CREATE UNIQUE INDEX flow_identity_shel_neos_workspacemodule_domain_model_work_e2fe7 ON shel_neos_workspacemodule_domain_model_workspacedetails (workspace)'); 30 | } 31 | 32 | public function down(Schema $schema): void 33 | { 34 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); 35 | 36 | $this->addSql('DROP TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join'); 37 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP FOREIGN KEY FK_923CCEA8D940019'); 38 | $this->addSql('DROP INDEX flow_identity_shel_neos_workspacemodule_domain_model_work_e2fe7 ON shel_neos_workspacemodule_domain_model_workspacedetails'); 39 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD workspacename VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, DROP workspace'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # An alternative workspace module with additional features for Neos CMS 2 | 3 | This Neos CMS plugin provides an alternative workspace module with added features: 4 | 5 | * New hierarchical workspace list 6 | * New dialogs for creation, deletion and editing of workspaces 7 | * Tracks and shows workspace creator, last editor and last publish date 8 | * Can remove unpublished changes and rebase dependent workspaces on delete 9 | * Share private workspaces with selected users 10 | 11 | ⚠️This plugin will be fully integrated into Neos 9.x and will be marked as deprecated afterward. ⚠️ 12 | Follow the integration on slack.neos.io in channel #project-new-workspace-module, or on [Github](https://github.com/neos/neos-development-collection/issues/4255). 13 | 14 | Support for the 8.3 version will be continued until support end of 8.3 LTS. 15 | 16 | ## Screenshots 17 | 18 | ### Module overview 19 | 20 | An overview with all available workspaces. 21 | Number of changes are shown as colored numbers instead a colored bar. 22 | 23 | ![Module overview](Documentation/Overview.png) 24 | 25 | ### Creation dialog 26 | 27 | Create private or public workspaces. 28 | Set a base workspace where the changes from your created workspace will be published to. 29 | 30 | ![Creation dialog](Documentation/CreateDialog.png) 31 | 32 | ### Editing dialog 33 | 34 | Edit workspaces you have managing rights for. 35 | 36 | ![Edit dialog](Documentation/EditDialog.png) 37 | 38 | ### Shared workspaces 39 | 40 | Private workspaces can be shared with other users. 41 | 42 | ![Share workspace](Documentation/SharedWorkspace.png) 43 | 44 | ### Deletion dialog 45 | 46 | Workspaces can be deleted at any time. 47 | 48 | **Notes:** 49 | 50 | * unpublished changes in the workspace will be discarded 51 | * dependent workspaces will be rebased on the base workspace 52 | 53 | ![Deletion dialog](Documentation/DeleteDialog.png) 54 | 55 | ## Installation 56 | 57 | Run 58 | 59 | ```console 60 | composer require shel/neos-workspace-module 61 | ``` 62 | 63 | Then apply database migrations 64 | 65 | ```console 66 | ./flow doctrine:migrate 67 | ``` 68 | 69 | ## Support 70 | 71 | * Neos 5.3 - 8.x 72 | * PostgreSQL & MySQL / MariaDB 73 | 74 | ## Detailed feature list 75 | 76 | * New workspace list 77 | * Sort by title or last modification data 78 | * Group workspaces by their parent (base) workspaces 79 | * Tracks & displays user and date of last change in a workspace 80 | * Stores original creator of a workspace 81 | * Nagging screen to remind user of their own stale workspaces 82 | * Optimised changes counts 83 | * Shows absolute number of changes instead of relation color bar 84 | * Async loading of changes counts in workspace overview 85 | * Shows disconnected nodes for workspace without valid changes 86 | * New workspace deletion dialog 87 | * Allows preview of consequences and confirm 88 | * Force deletion of workspaces with unpublished changes and dependent workspaces 89 | * Will rebase dependent workspaces 90 | * New workspace creation and editing dialog 91 | * New workspace will be created as public (internal) by default 92 | * Configurable workspace title validation 93 | * Select users to share a private workspace with 94 | 95 | ## License 96 | 97 | See [License](LICENSE.txt) 98 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Views/Index.fusion: -------------------------------------------------------------------------------- 1 | Shel.Neos.WorkspaceModule.WorkspacesController { 2 | index = Neos.Fusion:Component { 3 | username = ${username} 4 | userWorkspace = ${userWorkspace} 5 | baseWorkspaceOptions = ${baseWorkspaceOptions} 6 | userList = ${userList} 7 | workspaces = ${workspaces} 8 | csrfToken = ${csrfToken} 9 | userCanManageInternalWorkspaces = ${userCanManageInternalWorkspaces} 10 | validation = ${validation} 11 | flashMessages = ${flashMessages} 12 | 13 | endpoints = Neos.Fusion:DataStructure { 14 | deleteWorkspace = Neos.Fusion:UriBuilder { 15 | action = 'delete' 16 | arguments.workspace = '---workspace---' 17 | format = 'json' 18 | } 19 | pruneWorkspace = Neos.Fusion:UriBuilder { 20 | action = 'prune' 21 | arguments.workspace = '---workspace---' 22 | format = 'json' 23 | } 24 | updateWorkspace = Neos.Fusion:UriBuilder { 25 | action = 'update' 26 | format = 'json' 27 | } 28 | createWorkspace = Neos.Fusion:UriBuilder { 29 | action = 'create' 30 | format = 'json' 31 | } 32 | showWorkspace = Neos.Fusion:UriBuilder { 33 | action = 'show' 34 | arguments.workspace = '---workspace---' 35 | } 36 | getChanges = Neos.Fusion:UriBuilder { 37 | action = 'getChanges' 38 | format = 'json' 39 | } 40 | } 41 | 42 | renderer = afx` 43 |
44 |
45 |
54 | 57 | 60 | 63 | {I18n.translate('module.loadingText', 'Loading workspace module…', {}, 'Main', 'Shel.Neos.WorkspaceModule')} 64 |
65 | 66 |
67 |
68 | ` 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20230117134142.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 20 | 21 | // Remove all disconnected workspace details entities or the foreign table constraint will fail 22 | $this->addSql('DELETE FROM shel_neos_workspacemodule_domain_model_workspacedetails WHERE workspacename NOT IN (SELECT name FROM neos_contentrepository_domain_model_workspace)'); 23 | 24 | $this->addSql('CREATE TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join (workspacemodule_workspacedetails VARCHAR(40) NOT NULL, neos_user VARCHAR(40) NOT NULL, PRIMARY KEY(workspacemodule_workspacedetails, neos_user))'); 25 | $this->addSql('CREATE INDEX IDX_9EE667F39AF30FF3 ON shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join (workspacemodule_workspacedetails)'); 26 | $this->addSql('CREATE INDEX IDX_9EE667F3C7FF26B ON shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join (neos_user)'); 27 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join ADD CONSTRAINT FK_9EE667F39AF30FF3 FOREIGN KEY (workspacemodule_workspacedetails) REFERENCES shel_neos_workspacemodule_domain_model_workspacedetails (persistence_object_identifier) NOT DEFERRABLE INITIALLY IMMEDIATE'); 28 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join ADD CONSTRAINT FK_9EE667F3C7FF26B FOREIGN KEY (neos_user) REFERENCES neos_neos_domain_model_user (persistence_object_identifier) NOT DEFERRABLE INITIALLY IMMEDIATE'); 29 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails RENAME COLUMN workspacename TO workspace'); 30 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails ADD CONSTRAINT FK_923CCEA8D940019 FOREIGN KEY (workspace) REFERENCES neos_contentrepository_domain_model_workspace (name) NOT DEFERRABLE INITIALLY IMMEDIATE'); 31 | $this->addSql('CREATE UNIQUE INDEX flow_identity_shel_neos_workspacemodule_domain_model_work_e2fe7 ON shel_neos_workspacemodule_domain_model_workspacedetails (workspace)'); 32 | } 33 | 34 | public function down(Schema $schema): void 35 | { 36 | $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on "postgresql".'); 37 | 38 | $this->addSql('DROP TABLE shel_neos_workspacemodule_domain_model_workspace_1536f_acl_join'); 39 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails DROP CONSTRAINT FK_923CCEA8D940019'); 40 | $this->addSql('DROP INDEX flow_identity_shel_neos_workspacemodule_domain_model_work_e2fe7'); 41 | $this->addSql('ALTER TABLE shel_neos_workspacemodule_domain_model_workspacedetails RENAME COLUMN workspace TO workspacename'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Classes/Domain/Model/WorkspaceDetails.php: -------------------------------------------------------------------------------- 1 | 58 | */ 59 | protected $acl = []; 60 | 61 | public function __construct( 62 | Workspace $workspace, 63 | string $creator = null, 64 | \DateTime $lastChangedDate = null, 65 | string $lastChangedBy = null, 66 | ArrayCollection $acl = null 67 | ) { 68 | $this->workspace = $workspace; 69 | $this->creator = $creator; 70 | $this->lastChangedDate = $lastChangedDate ?? new \DateTime(); 71 | $this->lastChangedBy = $lastChangedBy ?? $creator; 72 | $this->acl = $acl ?? new ArrayCollection(); 73 | } 74 | 75 | public function getLastChangedDate(): ?\DateTime 76 | { 77 | return $this->lastChangedDate; 78 | } 79 | 80 | public function setLastChangedDate(?\DateTime $lastChangedDate): void 81 | { 82 | $this->lastChangedDate = $lastChangedDate; 83 | } 84 | 85 | public function getLastChangedBy(): ?string 86 | { 87 | return $this->lastChangedBy; 88 | } 89 | 90 | public function setLastChangedBy(?string $lastChangedBy): void 91 | { 92 | $this->lastChangedBy = $lastChangedBy; 93 | } 94 | 95 | public function getWorkspace(): Workspace 96 | { 97 | return $this->workspace; 98 | } 99 | 100 | public function getCreator(): ?string 101 | { 102 | return $this->creator; 103 | } 104 | 105 | public function setCreator(?string $creator): void 106 | { 107 | $this->creator = $creator; 108 | } 109 | 110 | /** 111 | * @param User[] $acl 112 | */ 113 | public function setAcl(array $acl): void 114 | { 115 | $this->acl = $acl; 116 | } 117 | 118 | /** 119 | * @return User[] 120 | */ 121 | public function getAcl(): array 122 | { 123 | return $this->acl instanceof Collection ? $this->acl->toArray() : $this->acl; 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /Classes/Aspect/SharedWorkspaceAccessAspect.php: -------------------------------------------------------------------------------- 1 | currentUserCanReadWorkspace())") 49 | * @Flow\Around("method(Neos\Neos\Domain\Service\UserService->currentUserCanPublishToWorkspace())") 50 | */ 51 | public function currentUserCanManageSharedWorkspace(JoinPointInterface $joinPoint): bool 52 | { 53 | $result = $joinPoint->getAdviceChain()->proceed($joinPoint); 54 | 55 | if ($result) { 56 | return true; 57 | } 58 | 59 | /** @var UserService $userService */ 60 | $userService = $joinPoint->getProxy(); 61 | $currentUser = $userService->getCurrentUser(); 62 | $workspace = $joinPoint->getMethodArgument('workspace'); 63 | 64 | return $currentUser 65 | && $workspace->isPrivateWorkspace() 66 | && $workspace->getOwner() !== $currentUser 67 | && $this->isWorkspaceSharedWithUser($workspace, $currentUser); 68 | } 69 | 70 | /** 71 | * Adjust workspace permission check for shared workspaces in the Neos UI 72 | * 73 | * @Flow\Around("method(Neos\Neos\Ui\ContentRepository\Service\WorkspaceService->getAllowedTargetWorkspaces())") 74 | * @return Workspace[] 75 | * @throws PropertyNotAccessibleException 76 | */ 77 | public function getAllowedTargetWorkspacesIncludingSharedOnes(JoinPointInterface $joinPoint): array 78 | { 79 | /** @var WorkspaceService $workspaceService */ 80 | $workspaceService = $joinPoint->getProxy(); 81 | /** @var UserService $userService */ 82 | $userService = ObjectAccess::getProperty($workspaceService, 'domainUserService', true); 83 | $workspaceRepository = ObjectAccess::getProperty($workspaceService, 'workspaceRepository', true); 84 | $user = $userService->getCurrentUser(); 85 | $sharedWorkspaceNames = $user ? $this->workspaceDetailsRepository->findAllowedWorkspaceNamesForUser($user) : []; 86 | 87 | $workspacesArray = []; 88 | /** @var Workspace $workspace */ 89 | foreach ($workspaceRepository->findAll() as $workspace) { 90 | // Skip personal workspaces and private workspace not shared with the current user 91 | if (!in_array($workspace->getName(), $sharedWorkspaceNames) 92 | && ( 93 | ($workspace->getOwner() !== null && $workspace->getOwner() !== $user) 94 | || $workspace->isPersonalWorkspace() 95 | ) 96 | ) { 97 | continue; 98 | } 99 | 100 | $workspaceArray = [ 101 | 'name' => $workspace->getName(), 102 | 'title' => $workspace->getTitle(), 103 | 'description' => $workspace->getDescription(), 104 | 'readonly' => !$userService->currentUserCanPublishToWorkspace($workspace) 105 | ]; 106 | $workspacesArray[$workspace->getName()] = $workspaceArray; 107 | } 108 | 109 | return $workspacesArray; 110 | } 111 | 112 | /** 113 | * Checks whether the given workspace is shared with the given user. 114 | */ 115 | protected function isWorkspaceSharedWithUser(Workspace $workspace, User $user): bool 116 | { 117 | $workspaceDetails = $this->workspaceDetailsRepository->findOneByWorkspace($workspace); 118 | if (!$workspaceDetails) { 119 | return false; 120 | } 121 | $allowedUsers = array_map(fn($user) => $this->persistenceManager->getIdentifierByObject($user), 122 | $workspaceDetails->getAcl()); 123 | return in_array($this->persistenceManager->getIdentifierByObject($user), $allowedUsers, true); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Resources/Private/Translations/en/Main.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loading workspace module… 7 | 8 | 9 | 10 | This workspace is owned by {owner} 11 | 12 | 13 | This is a shared workspace 14 | 15 | 16 | This workspace is owned by {owner} but allows access to additional users 17 | 18 | 19 | Title 20 | 21 | 22 | Last modified 23 | 24 | 25 | Changes 26 | 27 | 28 | Actions 29 | 30 | 31 | Creator 32 | 33 | 34 | Description 35 | 36 | 37 | 38 | None 39 | 40 | 41 | Review 42 | 43 | 44 | Show changes in workspace "{workspace}" 45 | 46 | 47 | No changes to review 48 | 49 | 50 | Edit workspace "{workspace}" 51 | 52 | 53 | Delete workspace "{workspace}" 54 | 55 | 56 | 57 | Create new workspace 58 | 59 | 60 | {total} workspaces ({internal} public, {private} private) 61 | 62 | 63 | 64 | Workspace is stale! 65 | 66 | 67 | (This is your workspace) 68 | 69 | 70 | {count} new nodes were added 71 | 72 | 73 | {count} nodes were changed 74 | 75 | 76 | {count} nodes were removed 77 | 78 | 79 | {count} nodes were changed but might be orphaned 80 | 81 | 82 | This workspace is stale and owned by you, consider deleting it. 83 | 84 | 85 | 86 | Cancel 87 | 88 | 89 | Create workspace 90 | 91 | 92 | Delete workspace "{workspace}" 93 | 94 | 95 | Save changes 96 | 97 | 98 | 99 | Create new workspace 100 | 101 | 102 | 103 | Edit workspace "{workspace}" 104 | 105 | 106 | 107 | Delete workspace "{workspace}"? 108 | 109 | 110 | This action cannot be undone. 111 | 112 | 113 | live workspace:]]> 114 | 115 | 116 | {count} private workspace(s)]]> 117 | 118 | 119 | {count} unpublished changes.]]> 120 | 121 | 122 | 123 | Prune workspace "{workspace}"? 124 | 125 | 126 | This action cannot be undone. 127 | 128 | 129 | {count} changes.]]> 130 | 131 | 132 | 133 | Title 134 | 135 | 136 | Allowed title pattern: 138 |
    139 |
  • Numbers
  • 140 |
  • Letters (upper & lowercase)
  • 141 |
  • Special characters: - _ [ ] . ( )
  • 142 |
  • First character needs to be an uppercase letter
  • 143 |
  • No trailing whitespace
  • 144 |
]]> 145 | 146 |
147 | 148 | Description 149 | 150 | 151 | Base workspace 152 | 153 | 154 | Owner 155 | 156 | 157 | Allow access for additional users: 158 | 159 | 160 | Filter by name 161 | 162 | 163 | No users found 164 | 165 | 166 | Visibility 167 | 168 | 169 | Public – Any logged in editor can see and modify this workspace. 170 | 171 | 172 | Any logged in editor can see and modify this workspace. 173 | 174 | 175 | Private – Only reviewers and administrators can access and modify this workspace. 176 | 177 | 178 | Only reviewers and administrators can access and modify this workspace. 179 | 180 | 181 | This is a personal workspace and only the owner can access and modify this workspace. 182 | 183 | 184 | 185 | The workspace "{workspaceName}" has been updated. 186 | 187 | 188 | The workspace "{workspaceName}" has been created. 189 | 190 | 191 | The workspace "{workspaceName}" has been removed, {unpublishedNodes} changes have been discarded and {dependentWorkspaces} dependent workspaces have been rebased. 192 | 193 | 194 | The workspace "{workspaceName}" has been pruned, {nodeCount} changes have been discarded. 195 | 196 | 197 | The workspace "{workspaceName}" is personal and cannot be deleted. 198 | 199 | 200 | Workspace "{dependentWorkspaceName}" has been rebased to "live" as it depends on workspace "{workspaceName}". 201 | 202 | 203 | There are no unpublished changes in workspace "{workspaceName}". 204 | 205 | 206 | 207 | The base workspaces of "{workspaceName}" cannot be resolved. 208 | 209 | 210 | You are not permitted to access the workspace "{workspaceName}". 211 | 212 | 213 | The workspace "{workspaceName}" could not be pruned. 214 | 215 | 216 | 217 | You own {0} stale workspaces. Please consider deleting them. 218 | 219 | 220 |
221 |
222 | -------------------------------------------------------------------------------- /Resources/Private/Translations/de/Main.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loading workspace module… 7 | Lade Arbeitsbereiche… 8 | 9 | 10 | 11 | This workspace is owned by {owner} 12 | Dieser Arbeitsbereich gehört {owner} 13 | 14 | 15 | This is a shared workspace 16 | Dies ist ein gemeinsamer Arbeitsbereich 17 | 18 | 19 | This workspace is owned by {owner} but allows access to additional users 20 | Dieser Arbeitsbereich gehört {owner}, erlaubt aber den Zugriff weiterer Benutzer 21 | 22 | 23 | Title 24 | Titel 25 | 26 | 27 | Last modified 28 | Letzte Änderung 29 | 30 | 31 | Changes 32 | Änderungen 33 | 34 | 35 | Actions 36 | Aktionen 37 | 38 | 39 | Creator 40 | Erstellt von 41 | 42 | 43 | Description 44 | Beschreibung 45 | 46 | 47 | 48 | None 49 | Keine 50 | 51 | 52 | Review 53 | Überprüfen 54 | 55 | 56 | Show changes in workspace "{workspace}" 57 | Änderungen im Arbeitsbereich "{workspace}" anzeigen 58 | 59 | 60 | No changes to review 61 | Keine Änderungen zur Überprüfung 62 | 63 | 64 | Edit workspace "{workspace}" 65 | Arbeitsbereich "{workspace}" bearbeiten 66 | 67 | 68 | Delete workspace "{workspace}" 69 | Arbeitsbereich "{workspace}" löschen 70 | 71 | 72 | 73 | Create new workspace 74 | Neuen Arbeitsbereich erstellen 75 | 76 | 77 | {total} workspaces ({internal} public, {private} private) 78 | {total} Arbeitsbereiche ({internal} öffentliche, {private} private) 79 | 80 | 81 | 82 | Workspace is stale! 83 | Arbeitsbereich wurde lange nicht verwendet! 84 | 85 | 86 | (This is your workspace) 87 | (Das ist dein Arbeitsbereich) 88 | 89 | 90 | {count} new nodes were added 91 | {count} neue Elemente wurden hinzugefügt 92 | 93 | 94 | {count} nodes were changed 95 | {count} Elemente wurden geändert 96 | 97 | 98 | {count} nodes were removed 99 | {count} Elemente wurden entfernt 100 | 101 | 102 | {count} nodes were changed but might be orphaned 103 | {count} wurden geändert, aber sind vermutlich nicht mehr erreichbar 104 | 105 | 106 | This workspace is stale and owned by you, consider deleting it. 107 | Dieser Arbeitsbereich ist veraltet und gehört Ihnen, Sie sollten ihn ggf. löschen. 108 | 109 | 110 | 111 | Cancel 112 | Abbrechen 113 | 114 | 115 | Create workspace 116 | Arbeitsbereich anlegen 117 | 118 | 119 | Delete workspace "{workspace}" 120 | Arbeitsbereich "{workspace}" löschen 121 | 122 | 123 | Save changes 124 | Änderungen speichern 125 | 126 | 127 | 128 | Create new workspace 129 | Neuen Arbeitsbereich erstellen 130 | 131 | 132 | 133 | Edit workspace "{workspace}" 134 | Arbeitsbereich "{workspace}" bearbeiten 135 | 136 | 137 | 138 | Delete workspace "{workspace}"? 139 | Arbeitsbereich "{workspace}" löschen? 140 | 141 | 142 | This action cannot be undone. 143 | Diese Aktion kann nicht rückgängig gemacht werden. 144 | 145 | 146 | live workspace:]]> 147 | Live-Arbeitsbereich verschoben:]]> 148 | 149 | 150 | {count} private workspace(s)]]> 151 | {count} private(r) Arbeitsbereich(e)]]> 152 | 153 | 154 | {count} unpublished changes.]]> 155 | {count} unveröffentlichte Änderungen verworfen.]]> 156 | 157 | 158 | 159 | Prune workspace "{workspace}"? 160 | Arbeitsbereich "{workspace}" leeren? 161 | 162 | 163 | This action cannot be undone. 164 | Diese Aktion kann nicht rückgängig gemacht werden. 165 | 166 | 167 | {count} changes.]]> 168 | {count} Änderungen verworfen.]]> 169 | 170 | 171 | 172 | Title 173 | Titel 174 | 175 | 176 | Allowed title pattern: 178 |
    179 |
  • Numbers
  • 180 |
  • Letters (upper & lowercase)
  • 181 |
  • Special characters: - _ [ ] . ( )
  • 182 |
  • First character needs to be an uppercase letter
  • 183 |
  • No trailing whitespace
  • 184 |
]]> 185 | 186 |
187 | 188 | Description 189 | Beschreibung 190 | 191 | 192 | Base workspace 193 | Basis-Arbeitsbereich 194 | 195 | 196 | Owner 197 | Eigentümer 198 | 199 | 200 | Allow access for additional users: 201 | Den Zugriff für weitere Benutzer erlauben: 202 | 203 | 204 | Filter by name 205 | Nach Namen filtern 206 | 207 | 208 | No users found 209 | Keine Benutzer gefunden 210 | 211 | 212 | Visibility 213 | Sichtbarkeit 214 | 215 | 216 | Public – Any logged in editor can see and modify this workspace. 217 | Öffentlich - eingeloggte Redakteur*innen können diesen Arbeitsbereich sehen und ändern. 218 | 219 | 220 | Any logged in editor can see and modify this workspace. 221 | Eingeloggte Redakteur*innen können diesen Arbeitsbereich sehen und ändern. 222 | 223 | 224 | Private – Only reviewers and administrators can access and modify this workspace. 225 | Privat - nur Prüfer*innen und Administrator*innen können auf diesen Arbeitsbereich zugreifen und ihn ändern. 226 | 227 | 228 | Only reviewers and administrators can access and modify this workspace. 229 | Nur Prüfer*innen und Administrator*innen können auf diesen Arbeitsbereich zugreifen und ihn ändern. 230 | 231 | 232 | This is a personal workspace and only the owner can access and modify this workspace. 233 | Dies ist ein persönlicher Arbeitsbereich, den Eigentümer*innen einsehen und ändern können. 234 | 235 | 236 | 237 | The workspace "{workspaceName}" has been updated. 238 | Der Arbeitsbereich "{workspaceName}" wurde aktualisiert. 239 | 240 | 241 | The workspace "{workspaceName}" has been created. 242 | Der Arbeitsbereich "{workspaceName}" wurde erstellt. 243 | 244 | 245 | The workspace "{workspaceName}" has been removed, {unpublishedNodes} changes have been discarded and {dependentWorkspaces} dependent workspaces have been rebased. 246 | Der Arbeitsbereich "{workspaceName}" wurde entfernt, {unpublishedNodes} Änderungen wurden verworfen und {dependentWorkspaces} abhängige Arbeitsbereiche wurden neu zugeordnet. 247 | 248 | 249 | The workspace "{workspaceName}" has been pruned, {nodeCount} changes have been discarded. 250 | Der Arbeitsbereich "{Arbeitsbereichsname}" wurde geleert, {Knotenanzahl} Änderungen wurden verworfen. 251 | 252 | 253 | The workspace "{workspaceName}" is personal and cannot be deleted. 254 | Der Arbeitsbereich "{workspaceName}" ist personengebunden und kann nicht gelöscht werden. 255 | 256 | 257 | Workspace "{dependentWorkspaceName}" has been rebased to "live" as it depends on workspace "{workspaceName}". 258 | Arbeitsbereich "{dependentWorkspaceName}" wurde auf "live" umgestellt, da er vom Arbeitsbereich "{workspaceName}" abhängt. 259 | 260 | 261 | There are no unpublished changes in workspace "{workspaceName}". 262 | Es gibt keine unveröffentlichten Änderungen im Arbeitsbereich "{workspaceName}". 263 | 264 | 265 | 266 | The base workspaces of "{workspaceName}" cannot be resolved. 267 | Die Basisarbeitsbereiche von "{workspaceName}" können nicht aufgelöst werden. 268 | 269 | 270 | You are not permitted to access the workspace "{workspaceName}". 271 | Sie sind nicht berechtigt, auf den Arbeitsbereich "{workspaceName}" zuzugreifen. 272 | 273 | 274 | The workspace "{workspaceName}" could not be pruned. 275 | Der Arbeitsbereich "{workspaceName}" konnte nicht geleert werden. 276 | 277 | 278 | 279 | You own {0} stale workspaces. Please consider deleting them. 280 | Sie besitzen {0} veraltete Arbeitsbereiche. Bitte erwägen Sie, sie zu löschen. 281 | 282 | 283 |
284 |
285 | -------------------------------------------------------------------------------- /Classes/Controller/WorkspacesController.php: -------------------------------------------------------------------------------- 1 | JsonView::class, 39 | ]; 40 | 41 | /** 42 | * @Flow\Inject 43 | * @var WorkspaceDetailsRepository 44 | */ 45 | protected $workspaceDetailsRepository; 46 | 47 | /** 48 | * @Flow\InjectConfiguration(path="staleTime") 49 | * @var int 50 | */ 51 | protected $staleTime; 52 | 53 | /** 54 | * @Flow\Inject 55 | * @var PrivilegeManagerInterface 56 | */ 57 | protected $privilegeManager; 58 | 59 | /** 60 | * @Flow\Inject 61 | * @var UserRepository 62 | */ 63 | protected $userRepository; 64 | 65 | public function indexAction(): void 66 | { 67 | $currentAccount = $this->securityContext->getAccount(); 68 | 69 | /** @var Workspace $userWorkspace */ 70 | $userWorkspace = $this->workspaceRepository->findOneByName( 71 | UserUtility::getPersonalWorkspaceNameForUsername($currentAccount->getAccountIdentifier()) 72 | ); 73 | 74 | $workspaceData = array_reduce( 75 | $this->workspaceRepository->findAll()->toArray(), 76 | function (array $carry, Workspace $workspace) { 77 | if ($this->userCanAccessWorkspace($workspace)) { 78 | $carry[$workspace->getName()] = $this->getWorkspaceInfo($workspace); 79 | } 80 | return $carry; 81 | }, 82 | [$userWorkspace->getName() => $this->getWorkspaceInfo($userWorkspace)] 83 | ); 84 | 85 | $this->view->assignMultiple([ 86 | 'username' => $currentAccount->getAccountIdentifier(), 87 | 'userWorkspace' => $userWorkspace, 88 | 'baseWorkspaceOptions' => $this->prepareBaseWorkspaceOptions(), 89 | 'userCanManageInternalWorkspaces' => $this->privilegeManager->isPrivilegeTargetGranted( 90 | 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' 91 | ), 92 | 'userList' => $this->prepareOwnerOptions(), 93 | 'workspaces' => $workspaceData, 94 | 'csrfToken' => $this->securityContext->getCsrfProtectionToken(), 95 | 'validation' => $this->settings['validation'], 96 | 'flashMessages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 97 | ]); 98 | } 99 | 100 | public function getChangesAction(): void 101 | { 102 | $currentAccount = $this->securityContext->getAccount(); 103 | 104 | /** @var Workspace $userWorkspace */ 105 | $userWorkspace = $this->workspaceRepository->findOneByName( 106 | UserUtility::getPersonalWorkspaceNameForUsername($currentAccount->getAccountIdentifier()) 107 | ); 108 | 109 | $workspaces = $this->workspaceRepository->findAll()->toArray(); 110 | 111 | $changesByWorkspace = array_reduce($workspaces, function ($carry, Workspace $workspace) { 112 | if ($this->userCanAccessWorkspace($workspace)) { 113 | $carry[$workspace->getName()] = $this->computeChangesCount($workspace); 114 | } 115 | return $carry; 116 | }, [ 117 | $userWorkspace->getName() => $this->computeChangesCount($userWorkspace), 118 | ]); 119 | 120 | $this->view->assign('value', ['changesByWorkspace' => $changesByWorkspace]); 121 | } 122 | 123 | /** 124 | * Delete a workspace and all contained unpublished changes. 125 | * Descendent workspaces will be rebased on the live workspace. 126 | * 127 | * @throws IllegalObjectTypeException 128 | * @Flow\SkipCsrfProtection 129 | */ 130 | public function deleteAction(Workspace $workspace): void 131 | { 132 | $success = false; 133 | /** @var Workspace[] $rebasedWorkspaces */ 134 | $rebasedWorkspaces = []; 135 | 136 | if ($workspace->isPersonalWorkspace() && $workspace->getOwner() !== null) { 137 | $this->addFlashMessage( 138 | $this->translateById('message.workspaceIsPersonal', ['workspaceName' => $workspace->getTitle()]), 139 | '', 140 | Message::SEVERITY_ERROR 141 | ); 142 | } else { 143 | $liveWorkspace = $this->workspaceRepository->findByIdentifier('live'); 144 | 145 | // Fetch and delete dependent workspaces for target workspace 146 | /** @var Workspace[] $dependentWorkspaces */ 147 | $dependentWorkspaces = $this->workspaceRepository->findByBaseWorkspace($workspace); 148 | foreach ($dependentWorkspaces as $dependentWorkspace) { 149 | $dependentWorkspace->setBaseWorkspace($liveWorkspace); 150 | $this->workspaceRepository->update($dependentWorkspace); 151 | $this->addFlashMessage( 152 | $this->translateById( 153 | 'message.workspaceRebased', 154 | [ 155 | 'dependentWorkspaceName' => $dependentWorkspace->getTitle(), 156 | 'workspaceName' => $workspace->getTitle(), 157 | ] 158 | ) 159 | , 160 | '', 161 | Message::SEVERITY_WARNING 162 | ); 163 | $rebasedWorkspaces[] = $dependentWorkspace; 164 | } 165 | 166 | // Fetch and discard unpublished nodes in target workspace 167 | $unpublishedNodes = []; 168 | try { 169 | $unpublishedNodes = $this->publishingService->getUnpublishedNodes($workspace); 170 | } catch (\Exception $exception) { 171 | } 172 | 173 | if ($unpublishedNodes) { 174 | $this->publishingService->discardNodes($unpublishedNodes); 175 | } 176 | 177 | $workspaceDetails = $this->workspaceDetailsRepository->findOneByWorkspace($workspace); 178 | 179 | if ($workspaceDetails) { 180 | $this->workspaceDetailsRepository->remove($workspaceDetails); 181 | } 182 | 183 | $this->workspaceRepository->remove($workspace); 184 | $this->addFlashMessage( 185 | $this->translateById( 186 | 'message.workspaceRemoved', 187 | [ 188 | 'workspaceName' => $workspace->getTitle(), 189 | 'unpublishedNodes' => count($unpublishedNodes), 190 | 'dependentWorkspaces' => count($dependentWorkspaces), 191 | ] 192 | ) 193 | ); 194 | $success = true; 195 | } 196 | 197 | $this->view->assign('value', [ 198 | 'success' => $success, 199 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 200 | 'rebasedWorkspaces' => array_map(static function ($workspace) { 201 | return $workspace->getName(); 202 | }, $rebasedWorkspaces), 203 | ]); 204 | } 205 | 206 | /** 207 | * Prune a workspace and all contained unpublished changes. 208 | * 209 | * @throws IllegalObjectTypeException 210 | * @Flow\SkipCsrfProtection 211 | */ 212 | public function pruneAction(Workspace $workspace): void 213 | { 214 | $success = false; 215 | $unpublishedNodes = $this->publishingService->getUnpublishedNodes($workspace); 216 | 217 | if (count($unpublishedNodes) > 0 && $this->userService->currentUserCanPublishToWorkspace($workspace)) { 218 | try { 219 | $this->publishingService->discardNodes($unpublishedNodes); 220 | 221 | $this->addFlashMessage( 222 | $this->translateById( 223 | 'message.workspacePruned', 224 | ['workspaceName' => $workspace->getTitle(), 'nodeCount' => count($unpublishedNodes)] 225 | ), 226 | ); 227 | 228 | // Persist the workspace and related data or the generated workspace info will be incomplete 229 | $this->persistenceManager->persistAll(); 230 | $success = true; 231 | } catch (\Exception $e) { 232 | $this->addFlashMessage( 233 | $this->translateById( 234 | 'error.pruneFailed', 235 | ['workspaceName' => $workspace->getTitle()] 236 | ), 237 | '', 238 | Message::SEVERITY_WARNING 239 | ); 240 | } 241 | } else { 242 | $this->addFlashMessage( 243 | $this->translateById( 244 | 'message.workspaceEmpty', 245 | ['workspaceName' => $workspace->getTitle()] 246 | ), 247 | '', 248 | Message::SEVERITY_WARNING 249 | ); 250 | } 251 | 252 | $this->view->assign('value', [ 253 | 'success' => $success, 254 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 255 | 'workspace' => $this->getWorkspaceInfo($workspace), 256 | ]); 257 | } 258 | 259 | protected function getWorkspaceInfo(Workspace $workspace): array 260 | { 261 | $workspaceDetails = $this->workspaceDetailsRepository->findOneByWorkspace($workspace); 262 | 263 | /** @var User $owner */ 264 | $owner = $workspace->getOwner(); 265 | $primaryOwnerAccount = $owner?->getAccounts()[0] ?? null; 266 | 267 | $creator = $creatorName = $lastChangedDate = $lastChangedBy = $lastChangedTimestamp = $isStale = null; 268 | $acl = []; 269 | 270 | if ($workspaceDetails) { 271 | $creator = $workspaceDetails->getCreator(); 272 | if ($creator) { 273 | $creatorUser = $this->userService->getUser($creator); 274 | $creatorName = $creatorUser ? $creatorUser->getLabel() : $creator; 275 | } 276 | $isStale = !$workspace->isPersonalWorkspace() && $workspaceDetails->getLastChangedDate( 277 | ) && $workspaceDetails->getLastChangedDate()->getTimestamp() < time() - $this->staleTime; 278 | 279 | if ($workspaceDetails->getLastChangedBy()) { 280 | $lastChangedBy = $this->userService->getUser($workspaceDetails->getLastChangedBy()); 281 | } 282 | $lastChangedDate = $workspaceDetails->getLastChangedDate() ? $workspaceDetails->getLastChangedDate( 283 | )->format('c') : null; 284 | $lastChangedTimestamp = $workspaceDetails->getLastChangedDate() ? $workspaceDetails->getLastChangedDate( 285 | )->getTimestamp() : null; 286 | $acl = $workspaceDetails->getAcl() ?? []; 287 | } 288 | 289 | // TODO: Introduce a DTO for this 290 | return [ 291 | 'name' => $workspace->getName(), 292 | 'title' => $workspace->getTitle(), 293 | 'description' => $workspace->getDescription(), 294 | 'owner' => $owner ? [ 295 | 'id' => $this->getUserId($owner), 296 | 'name' => $primaryOwnerAccount?->getAccountIdentifier(), 297 | 'label' => $owner->getLabel(), 298 | ] : null, 299 | 'baseWorkspace' => $workspace->getBaseWorkspace() ? [ 300 | 'name' => $workspace->getBaseWorkspace()->getName(), 301 | 'title' => $workspace->getBaseWorkspace()->getTitle(), 302 | ] : null, 303 | 'nodeCount' => $workspace->getNodeCount(), 304 | 'changesCounts' => null, // Will be retrieved async by the UI to speed up module loading time 305 | 'isPersonal' => $workspace->isPersonalWorkspace(), 306 | 'isInternal' => $workspace->isInternalWorkspace(), 307 | 'isStale' => $isStale, 308 | 'canPublish' => $this->userService->currentUserCanPublishToWorkspace($workspace), 309 | 'canManage' => $this->canManageWorkspace($workspace), 310 | 'dependentWorkspacesCount' => count($this->workspaceRepository->findByBaseWorkspace($workspace)), 311 | 'creator' => $creator ? [ 312 | 'id' => $creator, 313 | 'name' => $creator, 314 | 'label' => $creatorName, 315 | ] : null, 316 | 'lastChangedDate' => $lastChangedDate, 317 | 'lastChangedTimestamp' => $lastChangedTimestamp, 318 | 'lastChangedBy' => $lastChangedBy ? [ 319 | 'id' => $this->getUserId($lastChangedBy), 320 | 'label' => $lastChangedBy->getLabel(), 321 | ] : null, 322 | 'acl' => array_map(fn(User $user) => [ 323 | 'id' => $this->getUserId($user), 324 | 'label' => $user->getLabel(), 325 | ], $acl), 326 | ]; 327 | } 328 | 329 | /** 330 | * Create action from Neos WorkspacesController but creates a new WorkspaceDetails object after workspace creation 331 | * 332 | * @Flow\Validate(argumentName="title", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") 333 | * @param string $title Human friendly title of the workspace, for example "Christmas Campaign" 334 | * @param Workspace $baseWorkspace Workspace the new workspace should be based on 335 | * @param string $visibility Visibility of the new workspace, must be either "internal" or "shared" 336 | * @param string $description A description explaining the purpose of the new workspace 337 | */ 338 | public function createAction($title, Workspace $baseWorkspace, $visibility, $description = ''): void 339 | { 340 | $success = true; 341 | 342 | $workspace = $this->workspaceRepository->findOneByTitle($title); 343 | if ($workspace instanceof Workspace) { 344 | $this->addFlashMessage( 345 | $this->translator->translateById( 346 | 'workspaces.workspaceWithThisTitleAlreadyExists', 347 | [], 348 | null, 349 | null, 350 | 'Modules', 351 | 'Neos.Neos' 352 | ), 353 | '', 354 | Message::SEVERITY_WARNING 355 | ); 356 | $success = false; 357 | } else { 358 | $workspaceName = $this->renderWorkspaceName($title); 359 | // If a workspace with the generated name already exists, try again with a new name 360 | while ($this->workspaceRepository->findOneByName($workspaceName) instanceof Workspace) { 361 | $workspaceName = $this->renderWorkspaceName($title); 362 | } 363 | 364 | if ($visibility === 'private' || !$this->userCanManageInternalWorkspaces()) { 365 | $owner = $this->userService->getCurrentUser(); 366 | } else { 367 | $owner = null; 368 | } 369 | 370 | $workspace = new Workspace($workspaceName, $baseWorkspace, $owner); 371 | $workspace->setTitle($title); 372 | $workspace->setDescription($description); 373 | 374 | $this->workspaceRepository->add($workspace); 375 | 376 | // Create a new WorkspaceDetails object 377 | $workspaceDetails = new WorkspaceDetails( 378 | $workspace, 379 | $this->securityContext->getAccount()->getAccountIdentifier() 380 | ); 381 | $this->workspaceDetailsRepository->add($workspaceDetails); 382 | 383 | // Persist the workspace and related data or the generated workspace info will be incomplete 384 | $this->persistenceManager->persistAll(); 385 | 386 | $this->addFlashMessage( 387 | $this->translateById('message.workspaceCreated', ['workspaceName' => $workspace->getTitle()]), 388 | ); 389 | } 390 | 391 | $this->view->assign('value', [ 392 | 'success' => $success, 393 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 394 | 'workspace' => $this->getWorkspaceInfo($workspace), 395 | // Include a new list of base workspace options which might contain the new workspace depending on its visibility 396 | 'baseWorkspaceOptions' => $this->prepareBaseWorkspaceOptions(), 397 | ]); 398 | } 399 | 400 | /** 401 | * Returns a valid internal name for a new workspace based on the given title and the current timestamp 402 | */ 403 | protected function renderWorkspaceName(string $workspaceTitle): string 404 | { 405 | $timestamp = base_convert(microtime(false), 10, 36); 406 | $randomHash = substr($timestamp, -5, 5); 407 | return Utility::renderValidNodeName($workspaceTitle) . '-' . $randomHash; 408 | } 409 | 410 | /** 411 | * @inheritDoc 412 | */ 413 | protected function prepareBaseWorkspaceOptions(Workspace $excludedWorkspace = null): array 414 | { 415 | $options = parent::prepareBaseWorkspaceOptions($excludedWorkspace); 416 | asort($options, SORT_FLAG_CASE | SORT_NATURAL); 417 | return $options; 418 | } 419 | 420 | /** 421 | * @inheritDoc 422 | */ 423 | protected function prepareOwnerOptions(): array 424 | { 425 | $options = parent::prepareOwnerOptions(); 426 | asort($options, SORT_FLAG_CASE | SORT_NATURAL); 427 | return $options; 428 | } 429 | 430 | /** 431 | * @inheritDoc 432 | */ 433 | protected function initializeUpdateAction(): void 434 | { 435 | parent::initializeUpdateAction(); 436 | $workspaceConfiguration = $this->arguments['workspace']->getPropertyMappingConfiguration(); 437 | $workspaceConfiguration->allowAllProperties(); 438 | $workspaceConfiguration->setTypeConverterOption( 439 | PersistentObjectConverter::class, 440 | PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED, 441 | true 442 | ); 443 | } 444 | 445 | /** 446 | * @inheritDoc 447 | */ 448 | public function updateAction(Workspace $workspace): void 449 | { 450 | $success = false; 451 | if ($workspace->getTitle() === '') { 452 | $workspace->setTitle($workspace->getName()); 453 | } 454 | 455 | if (!$this->validateWorkspaceChain($workspace)) { 456 | $this->addFlashMessage( 457 | $this->translateById( 458 | 'error.invalidWorkspaceChain', 459 | ['workspaceName' => $workspace->getTitle()] 460 | ), 461 | '', 462 | Message::SEVERITY_ERROR 463 | ); 464 | } else { 465 | $workspaceDetails = $this->workspaceDetailsRepository->findOneByWorkspace($workspace); 466 | 467 | if (!$workspaceDetails) { 468 | $workspaceDetails = new WorkspaceDetails($workspace); 469 | $this->workspaceDetailsRepository->add($workspaceDetails); 470 | } 471 | 472 | // Update access control list 473 | $providedAcl = $this->request->hasArgument('acl') ? $this->request->getArgument('acl') ?? [] : []; 474 | $acl = $workspace->getOwner() ? $providedAcl : []; 475 | $allowedUsers = array_map(fn($userName) => $this->userRepository->findByIdentifier($userName), $acl); 476 | 477 | // Rebase users if they were using the workspace but lost access by the update 478 | $allowedAccounts = array_map( 479 | static fn(User $user) => (string)$user->getAccounts()->first()->getAccountIdentifier(), 480 | $allowedUsers 481 | ); 482 | $liveWorkspace = $this->workspaceRepository->findByIdentifier('live'); 483 | foreach ($workspaceDetails->getAcl() as $prevAcl) { 484 | $aclAccount = $prevAcl->getAccounts()->first()->getAccountIdentifier(); 485 | if (!in_array($aclAccount, $allowedAccounts, true)) { 486 | /** @var Workspace $userWorkspace */ 487 | $userWorkspace = $this->workspaceRepository->findOneByName( 488 | UserUtility::getPersonalWorkspaceNameForUsername($aclAccount) 489 | ); 490 | if ($userWorkspace->getBaseWorkspace() === $workspace) { 491 | $userWorkspace->setBaseWorkspace($liveWorkspace); 492 | $this->workspaceRepository->update($userWorkspace); 493 | } 494 | } 495 | } 496 | 497 | $workspaceDetails->setAcl($allowedUsers); 498 | 499 | $this->workspaceRepository->update($workspace); 500 | $this->workspaceDetailsRepository->update($workspaceDetails); 501 | $this->persistenceManager->persistAll(); 502 | 503 | $this->addFlashMessage( 504 | $this->translateById('message.workspaceUpdated', ['workspaceName' => $workspace->getTitle()]), 505 | ); 506 | $success = true; 507 | } 508 | 509 | $this->view->assign('value', [ 510 | 'success' => $success, 511 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 512 | 'workspace' => $this->getWorkspaceInfo($workspace), 513 | 'baseWorkspaceOptions' => $this->prepareBaseWorkspaceOptions(), 514 | ]); 515 | } 516 | 517 | /** 518 | * @inheritDoc 519 | * 520 | * @param array $nodes 521 | * @param string $action 522 | * @param Workspace|null $selectedWorkspace 523 | * @throws \Exception|PropertyException|SecurityException 524 | */ 525 | public function publishOrDiscardNodesAction(array $nodes, $action, Workspace $selectedWorkspace = null): void 526 | { 527 | $this->validateWorkspaceAccess($selectedWorkspace); 528 | 529 | $propertyMappingConfiguration = $this->propertyMapper->buildPropertyMappingConfiguration(); 530 | $propertyMappingConfiguration->setTypeConverterOption( 531 | NodeConverter::class, 532 | NodeConverter::REMOVED_CONTENT_SHOWN, 533 | true 534 | ); 535 | foreach ($nodes as $key => $node) { 536 | $nodes[$key] = $this->propertyMapper->convert($node, NodeInterface::class, $propertyMappingConfiguration); 537 | } 538 | 539 | switch ($action) { 540 | case 'publish': 541 | foreach ($nodes as $node) { 542 | $this->publishingService->publishNode($node); 543 | } 544 | $this->addFlashMessage( 545 | $this->translator->translateById( 546 | 'workspaces.selectedChangesHaveBeenPublished', 547 | [], 548 | null, 549 | null, 550 | 'Modules', 551 | 'Neos.Neos' 552 | ) 553 | ); 554 | break; 555 | case 'discard': 556 | $this->publishingService->discardNodes($nodes); 557 | $this->addFlashMessage( 558 | $this->translator->translateById( 559 | 'workspaces.selectedChangesHaveBeenDiscarded', 560 | [], 561 | null, 562 | null, 563 | 'Modules', 564 | 'Neos.Neos' 565 | ) 566 | ); 567 | break; 568 | default: 569 | throw new \RuntimeException('Invalid action "' . htmlspecialchars($action) . '" given.', 1652703800); 570 | } 571 | 572 | $this->redirect('show', null, null, ['workspace' => $selectedWorkspace]); 573 | } 574 | 575 | public function publishWorkspaceAction(Workspace $workspace): void 576 | { 577 | $this->validateWorkspaceAccess($workspace); 578 | parent::publishWorkspaceAction($workspace); 579 | } 580 | 581 | public function discardWorkspaceAction(Workspace $workspace): void 582 | { 583 | $this->validateWorkspaceAccess($workspace); 584 | parent::discardWorkspaceAction($workspace); 585 | } 586 | 587 | public function showAction(Workspace $workspace): void 588 | { 589 | $this->validateWorkspaceAccess($workspace); 590 | parent::showAction($workspace); 591 | } 592 | 593 | protected function getUserId(User $user): string 594 | { 595 | return $this->persistenceManager->getIdentifierByObject($user); 596 | } 597 | 598 | protected function validateWorkspaceAccess(Workspace $workspace = null): void 599 | { 600 | if ($workspace && !$this->userCanAccessWorkspace($workspace)) { 601 | $this->translator->translateById( 602 | 'error.workspaceInaccessible', 603 | ['workspaceName' => $workspace->getName()], 604 | null, 605 | null, 606 | 'Main', 607 | 'Shel.Neos.WorkspaceModule' 608 | ); 609 | $this->redirect('index'); 610 | } 611 | } 612 | 613 | /** 614 | * Checks whether the current user can access the given workspace. 615 | * The check via the `userService` is modified via an aspect to allow access to the workspace if the 616 | * workspace is specifically allowed for the user. 617 | */ 618 | protected function userCanAccessWorkspace(Workspace $workspace): bool 619 | { 620 | return $workspace->getName() !== 'live' && ($workspace->isInternalWorkspace( 621 | ) || $this->userService->currentUserCanReadWorkspace($workspace)); 622 | } 623 | 624 | private function userCanManageInternalWorkspaces(): bool 625 | { 626 | return $this->privilegeManager->isPrivilegeTargetGranted( 627 | 'Neos.Neos:Backend.Module.Management.Workspaces.ManageInternalWorkspaces' 628 | ); 629 | } 630 | 631 | protected function translateById(string $id, array $arguments = []): string 632 | { 633 | return $this->translator->translateById( 634 | $id, 635 | $arguments, 636 | null, 637 | null, 638 | 'Main', 639 | 'Shel.Neos.WorkspaceModule' 640 | ) ?? $id; 641 | } 642 | 643 | /** 644 | * Checks whether a workspace base workspace chain can be fully resolved without circular references 645 | */ 646 | protected function validateWorkspaceChain(Workspace $workspace): bool 647 | { 648 | $baseWorkspaces = [$workspace->getName()]; 649 | $currentWorkspace = $workspace; 650 | while ($currentWorkspace = $currentWorkspace->getBaseWorkspace()) { 651 | if (in_array($currentWorkspace->getName(), $baseWorkspaces, true)) { 652 | return false; 653 | } 654 | $baseWorkspaces[] = $currentWorkspace->getName(); 655 | } 656 | return true; 657 | } 658 | 659 | protected function canManageWorkspace(Workspace $workspace): bool 660 | { 661 | // Workaround to make personal workspaces with missing owners manageable 662 | if ($workspace->isPersonalWorkspace() && !$workspace->getOwner()) { 663 | return $this->privilegeManager->isPrivilegeTargetGranted('Neos.Neos:Backend.Module.Management.Workspaces.ManageAllPrivateWorkspaces'); 664 | } 665 | return $this->userService->currentUserCanManageWorkspace($workspace); 666 | } 667 | 668 | } 669 | --------------------------------------------------------------------------------