├── 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 | 
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 | 
31 |
32 | ### Editing dialog
33 |
34 | Edit workspaces you have managing rights for.
35 |
36 | 
37 |
38 | ### Shared workspaces
39 |
40 | Private workspaces can be shared with other users.
41 |
42 | 
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 | 
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 |
21 |
24 |
27 |
30 |
33 |
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 |
59 |
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 |
101 |
102 |
105 |
106 |
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 |
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 |
26 |
30 |
34 |
38 |
42 |
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 |
76 |
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 |
131 |
132 |
136 |
137 |
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 |
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 |
--------------------------------------------------------------------------------