diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..84423cc
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git/
+.idea/
+node_modules/
+vendor/
+Packages
+.github
+.claude
+.idea
+composer.lock
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..2e4f165
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,60 @@
+name: E2E Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ e2e:
+ name: E2E Tests (${{ matrix.neos }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ neos: [neos8, neos9]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: Tests/E2E/.nvmrc
+ cache: npm
+ cache-dependency-path: Tests/E2E/package-lock.json
+
+ - name: Install dependencies
+ working-directory: Tests/E2E
+ run: npm ci
+
+ - name: Install Playwright browsers
+ working-directory: Tests/E2E
+ run: npx playwright install --with-deps chromium
+
+ - name: Pre-build Docker image
+ run: docker compose -f Tests/system_under_test/${{ matrix.neos }}/docker-compose.yaml build --pull
+
+ - name: Test - defaults
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:defaults
+
+ - name: Test - enforce-all
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:enforce-all
+
+ - name: Test - enforce-role
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:enforce-role
+
+ - name: Test - enforce-provider
+ working-directory: Tests/E2E
+ run: npm run test:${{ matrix.neos }}:enforce-provider
+
+ - name: Upload Playwright report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report-${{ matrix.neos }}
+ path: Tests/E2E/playwright-report/
+ retention-days: 7
diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php
index 5e99390..000462a 100644
--- a/Classes/Controller/BackendController.php
+++ b/Classes/Controller/BackendController.php
@@ -137,7 +137,7 @@ public function newAction(): void
* @throws IllegalObjectTypeException
* @throws StopActionException
*/
- public function createAction(string $secret, string $secondFactorFromApp): void
+ public function createAction(string $secret, string $secondFactorFromApp, string $name = ''): void
{
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
@@ -157,7 +157,7 @@ public function createAction(string $secret, string $secondFactorFromApp): void
$this->redirect('new');
}
- $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount());
+ $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount(), $name);
$this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED);
@@ -189,7 +189,9 @@ public function deleteAction(SecondFactor $secondFactor): void
if ($isAdministrator || ($isOwner && $this->secondFactorService->canOneSecondFactorBeDeletedForAccount($account))) {
// User is admin or has more than one second factor
$this->secondFactorRepository->remove($secondFactor);
- $this->persistenceManager->persistAll();
+ // neos8 backwards compatibility
+ $this->persistenceManager?->persistAll();
+
$this->addFlashMessage(
$this->translator->translateById(
'module.index.delete.flashMessage.secondFactorDeleted',
diff --git a/Classes/Controller/LoginController.php b/Classes/Controller/LoginController.php
index 33c7ebc..2a59f6c 100644
--- a/Classes/Controller/LoginController.php
+++ b/Classes/Controller/LoginController.php
@@ -177,12 +177,13 @@ public function setupSecondFactorAction(?string $username = null): void
/**
* @param string $secret
* @param string $secondFactorFromApp
+ * @param string $name
* @return void
* @throws IllegalObjectTypeException
* @throws SessionNotStartedException
* @throws StopActionException
*/
- public function createSecondFactorAction(string $secret, string $secondFactorFromApp): void
+ public function createSecondFactorAction(string $secret, string $secondFactorFromApp, string $name = ''): void
{
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
@@ -204,7 +205,7 @@ public function createSecondFactorAction(string $secret, string $secondFactorFro
$account = $this->securityContext->getAccount();
- $this->secondFactorRepository->createSecondFactorForAccount($secret, $account);
+ $this->secondFactorRepository->createSecondFactorForAccount($secret, $account, $name);
$this->addFlashMessage(
$this->translator->translateById(
diff --git a/Classes/Domain/Model/SecondFactor.php b/Classes/Domain/Model/SecondFactor.php
index 569d9d4..bbde9d5 100644
--- a/Classes/Domain/Model/SecondFactor.php
+++ b/Classes/Domain/Model/SecondFactor.php
@@ -8,6 +8,8 @@
use Doctrine\ORM\Mapping as ORM;
use Neos\Flow\Annotations as Flow;
+// TODO: refactor to PHP8 code
+
/**
* Store the secrets needed for two factor authentication
*
@@ -39,6 +41,11 @@ class SecondFactor
*/
protected string $secret;
+ /**
+ * @var string
+ */
+ protected string $name;
+
/**
* Introduced with version 1.4.0
* Nullable for backwards compatibility. Null values will be shown as '-' in backend module.
@@ -73,6 +80,7 @@ public function getType(): int
}
/**
+ * Used in Fusion rendering
* @return string
*/
public function getTypeAsName(): string
@@ -104,6 +112,19 @@ public function setSecret(string $secret): void
$this->secret = $secret;
}
+ public function setName(string $name): void
+ {
+ $this->name = $name;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * Used in Fusion rendering
+ */
public function getCreationDate(): DateTime|null
{
return $this->creationDate;
diff --git a/Classes/Domain/Repository/SecondFactorRepository.php b/Classes/Domain/Repository/SecondFactorRepository.php
index 08aa1fe..8e076b4 100644
--- a/Classes/Domain/Repository/SecondFactorRepository.php
+++ b/Classes/Domain/Repository/SecondFactorRepository.php
@@ -24,12 +24,13 @@ class SecondFactorRepository extends Repository
/**
* @throws IllegalObjectTypeException
*/
- public function createSecondFactorForAccount(string $secret, Account $account): void
+ public function createSecondFactorForAccount(string $secret, Account $account, string $name): void
{
$secondFactor = new SecondFactor();
$secondFactor->setAccount($account);
$secondFactor->setSecret($secret);
$secondFactor->setType(SecondFactor::TYPE_TOTP);
+ $secondFactor->setName($name);
$secondFactor->setCreationDate(new \DateTime());
$this->add($secondFactor);
$this->persistenceManager->persistAll();
diff --git a/Concept_Composer_Package.md b/Concept_Composer_Package.md
new file mode 100644
index 0000000..1478939
--- /dev/null
+++ b/Concept_Composer_Package.md
@@ -0,0 +1,169 @@
+# Concept: Reusable E2E Infrastructure as a Composer Package
+
+## Goal
+
+Extract the E2E test infrastructure from this plugin into a standalone Composer package
+(`sandstorm/neos-e2e-testing`) that any Neos plugin can install to get a working
+end-to-end test scaffold with minimal effort.
+
+**Scope:** Infrastructure only — Docker/SUT setup, Playwright project scaffold (config,
+package.json, tsconfig, teardown), Makefile, and GitHub Actions workflow. Step definitions,
+helpers (system, pages, totp, etc.), and feature files are entirely the plugin developer's
+responsibility.
+
+---
+
+## Package Structure
+
+### Type: `library`
+
+Not `neos-package` — this is a dev tool. It belongs in `vendor/`, not `Packages/`, and
+has no Flow/Neos runtime dependency.
+
+```
+sandstorm/neos-e2e-testing/
+├── composer.json
+├── bin/
+│ └── neos-e2e-setup ← self-contained PHP CLI script
+└── templates/
+ ├── Tests/
+ │ ├── E2E/
+ │ │ ├── package.json
+ │ │ ├── playwright.config.ts
+ │ │ ├── tsconfig.json
+ │ │ ├── global-teardown.ts
+ │ │ └── .nvmrc
+ │ └── system_under_test/
+ │ ├── neos8/
+ │ │ ├── Dockerfile
+ │ │ ├── docker-compose.yaml
+ │ │ └── sut-files/
+ │ │ ├── entrypoint.sh
+ │ │ ├── etc/caretakerd.yaml
+ │ │ ├── etc/frankenphp/Caddyfile
+ │ │ └── usr/local/etc/php/conf.d/php-ini-overrides.ini
+ │ └── neos9/
+ │ └── (same)
+ ├── .dockerignore
+ ├── Makefile
+ └── .github/
+ └── workflows/
+ └── e2e.yml
+```
+
+### `composer.json`
+
+```json
+{
+ "name": "sandstorm/neos-e2e-testing",
+ "type": "library",
+ "description": "Scaffolds E2E test infrastructure for Neos plugins",
+ "require": { "php": "^8.1" },
+ "bin": ["bin/neos-e2e-setup"]
+}
+```
+
+---
+
+## Usage
+
+```sh
+composer require --dev sandstorm/neos-e2e-testing
+vendor/bin/neos-e2e-setup --plugin-package sandstorm/neostwofactorauthentication
+```
+
+The script copies templates into the plugin root, substitutes tokens, and skips files that
+already exist (safe to re-run).
+
+---
+
+## Template Tokens
+
+Only two things vary between plugins:
+
+| Token | Example | Used in |
+|---|---|---|
+| `{{PLUGIN_PACKAGE}}` | `sandstorm/neostwofactorauthentication` | Dockerfile, docker-compose project name, Makefile, GH Actions |
+| `{{PLUGIN_SLUG}}` | `sandstorm-2fa` | Docker container/volume names |
+
+The slug is derived automatically from the package name (everything after `/`, hyphens
+preserved), so the developer only needs to provide `--plugin-package`.
+
+---
+
+## The Dockerfile Template
+
+The only plugin-specific lines in the Dockerfile are the `composer require` call. These
+are made generic via a build arg:
+
+```dockerfile
+ARG PLUGIN_PACKAGE={{PLUGIN_PACKAGE}}
+
+COPY . /tmp/plugin/
+RUN --mount=type=cache,target=/root/.composer \
+ composer config repositories.plugin \
+ '{"type":"path","url":"/tmp/plugin","options":{"symlink":false}}' \
+ && composer require ${PLUGIN_PACKAGE}:@dev
+```
+
+Everything else (PHP extensions, caretakerd, FrankenPHP, Neos base distribution, config
+copy) is already generic across plugins.
+
+---
+
+## `.dockerignore` (generated at plugin root)
+
+Without this, `COPY . /tmp/plugin/` in the Dockerfile would include the test
+infrastructure itself inside the image:
+
+```
+Tests/E2E/node_modules
+Tests/E2E/.playwright
+Tests/system_under_test
+```
+
+---
+
+## The `bin/neos-e2e-setup` Script
+
+A self-contained PHP script — no autoloading, no framework. It:
+
+1. Parses `--plugin-package` from `$argv`
+2. Derives `{{PLUGIN_SLUG}}` from the package name
+3. Iterates over `templates/` recursively
+4. For each template file: substitutes tokens, copies to the target path
+5. Skips files that already exist (idempotent)
+
+---
+
+## What the Developer Adds After Scaffolding
+
+The scaffolded layout has intentional empty directories (with `.gitkeep`) for the
+plugin-specific content:
+
+```
+Tests/E2E/features/
+ ← write .feature files here
+
+Tests/E2E/steps/
+ ← write step definitions here
+
+Tests/system_under_test/neos8/sut-files/app/Configuration/Production/E2E-SUT/
+Tests/system_under_test/neos9/sut-files/app/Configuration/Production/E2E-SUT/
+ ← plugin-specific Settings.yaml, Policy.yaml, context subdirectories, etc.
+```
+
+The scaffold writes the base `Configuration/Production/E2E-SUT/Settings.yaml` (DB + Redis
+connection) and `Caches.yaml`. The plugin only adds its own configuration on top.
+
+---
+
+## Open Question: Container Name Convention
+
+The `SUT` environment variable (used in step definitions as `${SUT}-neos-1` to target
+`docker exec`) is a contract between the scaffolded `docker-compose.yaml` and the
+developer's step helpers. Since step helpers are entirely the plugin developer's business,
+this convention needs to be documented somewhere visible — either in a generated `README`
+in `Tests/E2E/`, or as a comment in the scaffolded `playwright.config.ts`.
+
+A decision is needed on where this contract lives before the package is built.
diff --git a/Configuration/Settings.2FA.yaml b/Configuration/Settings.2FA.yaml
new file mode 100644
index 0000000..3ee0629
--- /dev/null
+++ b/Configuration/Settings.2FA.yaml
@@ -0,0 +1,10 @@
+Sandstorm:
+ NeosTwoFactorAuthentication:
+ # enforce 2FA for all users
+ enforceTwoFactorAuthentication: false
+ # enforce 2FA for specific authentication providers (e.g. Neos.Neos:Backend)
+ enforce2FAForAuthenticationProviders : []
+ # enforce 2FA for specific roles (e.g. Neos.Neos:Administrator)
+ enforce2FAForRoles: []
+ # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used
+ issuerName: ''
diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml
index 559bc32..a5d32b1 100644
--- a/Configuration/Settings.yaml
+++ b/Configuration/Settings.yaml
@@ -48,14 +48,3 @@ Neos:
pattern: 'ControllerObjectName'
patternOptions:
controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)'
-
-Sandstorm:
- NeosTwoFactorAuthentication:
- # enforce 2FA for all users
- enforceTwoFactorAuthentication: false
- # enforce 2FA for specific authentication providers
- enforce2FAForAuthenticationProviders : []
- # enforce 2FA for specific roles
- enforce2FAForRoles: []
- # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used
- issuerName: ''
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2e9cf6f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,101 @@
+NEOS8_COMPOSE = $(CURDIR)/Tests/system_under_test/neos8/docker-compose.yaml
+NEOS9_COMPOSE = $(CURDIR)/Tests/system_under_test/neos9/docker-compose.yaml
+E2E_DIR = $(CURDIR)/Tests/E2E
+
+.SILENT:
+.PHONY: setup setup-sut setup-test generate-bdd-files test test-neos8 test-neos9 \
+ test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider test-neos8-issuer-name \
+ test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider test-neos9-issuer-name \
+ start-sut-neos8 start-sut-neos9 log-sut-neos8 log-sut-neos9 enter-sut-neos8 enter-sut-neos9 sut-down
+
+# COLORS
+GREEN := $(shell tput -Txterm setaf 2)
+YELLOW := $(shell tput -Txterm setaf 3)
+RESET := $(shell tput -Txterm sgr0)
+
+# initial setup
+setup: setup-sut setup-test
+
+setup-sut:
+ docker compose -f $(NEOS8_COMPOSE) build --pull
+ docker compose -f $(NEOS9_COMPOSE) build --pull
+
+setup-test:
+ echo "${GREEN}Installing test setup.${RESET}"
+ cd $(E2E_DIR) && \
+ if [ -s "$$NVM_DIR" ]; then \
+ . "$$NVM_DIR/nvm.sh" && echo "${GREEN}Found nvm on system -> using it to install nodejs!${RESET}" && nvm install; \
+ fi && \
+ npm install && npx playwright install --with-deps chromium && \
+ echo "" && echo "${GREEN}generate BDD files from feature files${RESET}" && npm run generate-tests
+
+# generate BDD files from feature files
+generate-bdd-files:
+ echo "${GREEN}generate BDD files from feature files${RESET}"; \
+ cd $(E2E_DIR) && npm run generate-tests
+
+## Run all E2E tests
+test: test-neos8 test-neos9
+
+## Run all neos8 E2E tests
+test-neos8: test-neos8-defaults test-neos8-enforce-all test-neos8-enforce-role test-neos8-enforce-provider
+
+## Run all neos9 E2E tests
+test-neos9: test-neos9-defaults test-neos9-enforce-all test-neos9-enforce-role test-neos9-enforce-provider
+
+test-neos8-defaults:
+ cd $(E2E_DIR) && npm run test:neos8:defaults
+
+test-neos8-enforce-all:
+ cd $(E2E_DIR) && npm run test:neos8:enforce-all
+
+test-neos8-enforce-role:
+ cd $(E2E_DIR) && npm run test:neos8:enforce-role
+
+test-neos8-enforce-provider:
+ cd $(E2E_DIR) && npm run test:neos8:enforce-provider
+
+test-neos8-issuer-name:
+ cd $(E2E_DIR) && npm run test:neos8:issuer-name
+
+test-neos9-defaults:
+ cd $(E2E_DIR) && npm run test:neos9:defaults
+
+test-neos9-enforce-all:
+ cd $(E2E_DIR) && npm run test:neos9:enforce-all
+
+test-neos9-enforce-role:
+ cd $(E2E_DIR) && npm run test:neos9:enforce-role
+
+test-neos9-enforce-provider:
+ cd $(E2E_DIR) && npm run test:neos9:enforce-provider
+
+test-neos9-issuer-name:
+ cd $(E2E_DIR) && npm run test:neos9:issuer-name
+
+## Start SUT containers
+start-sut-neos8:
+ docker compose -f $(NEOS8_COMPOSE) up -d --build
+
+start-sut-neos9:
+ docker compose -f $(NEOS9_COMPOSE) up -d --build
+
+## Follow logs of SUT containers
+log-sut-neos8:
+ docker compose -f $(NEOS8_COMPOSE) logs -f
+
+log-sut-neos9:
+ docker compose -f $(NEOS9_COMPOSE) logs -f
+
+## Enter SUT containers
+enter-sut-neos8:
+ docker compose -f $(NEOS8_COMPOSE) exec neos bash
+
+enter-sut-neos9:
+ docker compose -f $(NEOS9_COMPOSE) exec neos bash
+
+## Tear down all docker compose environments and remove volumes
+sut-down:
+ echo "${YELLOW}Shutting down all SUTs and removing their volumes.${RESET}"
+ docker compose -f $(NEOS8_COMPOSE) down -v
+ docker compose -f $(NEOS9_COMPOSE) down -v
diff --git a/Migrations/Mysql/Version20260325141345.php b/Migrations/Mysql/Version20260325141345.php
new file mode 100644
index 0000000..121dabf
--- /dev/null
+++ b/Migrations/Mysql/Version20260325141345.php
@@ -0,0 +1,41 @@
+abortIf(
+ !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform,
+ "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'."
+ );
+
+ $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD name VARCHAR(255) NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->abortIf(
+ !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform,
+ "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'."
+ );
+
+ $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor DROP name');
+ }
+}
diff --git a/README.md b/README.md
index 4573608..2c1f8bf 100644
--- a/README.md
+++ b/README.md
@@ -177,3 +177,91 @@ causes the same exception again. We get caught in an endless redirect.
The [Neos Flow Security Documentation](https://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Security.html#multi-factor-authentication-strategy)
suggests how to implement a multi-factor-authentication, but this method seems like it was never tested. At the moment of writing
it seems like the `authenticationStrategy: allTokens` flag is broken and not usable.
+
+## Contributing
+
+### Testing
+
+The package ships with end-to-end tests built on [Playwright](https://playwright.dev) and written in Gherkin syntax via [playwright-bdd](https://vitalets.github.io/playwright-bdd/).
+
+#### Running the tests
+
+Tests require Docker and Node.js. Install dependencies once (if [nvm](https://github.com/nvm-sh/nvm) is available it will automatically switch to the Node version from `.nvmrc`):
+
+```bash
+make setup-test
+```
+
+Re-generate Playwright spec files whenever a `.feature` file changes:
+```bash
+make generate-bdd-files
+```
+
+Use the Makefile targets from the repository root:
+
+```bash
+make test # run all tests (neos8 + neos9, all configurations)
+
+make test-neos8 # run all neos8 tests
+make test-neos8-defaults # default configuration only
+make test-neos8-enforce-all # enforceTwoFactorAuthentication: true
+make test-neos8-enforce-role
+make test-neos8-enforce-provider
+make test-neos8-issuer-name
+
+make test-neos9 # same targets for neos9 / PHP 8.3
+
+make down # tear down all docker compose environments and remove volumes
+```
+
+#### Debugging tests
+To debug a test, run the test from `Tests/E2E/` with flags like this:
+
+- `npm run test:neos8:enforce-all -- --debug` - to run the test in headed mode with Playwright Inspector
+- `npm run test:neos8:enforce-all -- --ui` - to run the test in headed mode with Playwright Test Runner UI
+
+If you just want to see the test running in the browser just `npm run test:neos8:enforce-all -- --headed`.
+
+> While debugging you can also enter the SUT with `make enter-neos8` and `make enter-neos9` respectively.
+>
+> You can even the tests you want to debug with `npm run test:neos8:enforce-all -- --grep @debug` and adding the `@debug` tag to the scenario you want to debug. But using the --ui flag is usually more convenient for debugging.
+
+#### System under test (SUT)
+
+There are two docker compose environments in `Tests/system_under_test/`:
+
+- `neos8/` — Neos with PHP 8.2
+- `neos9/` — Neos with PHP 8.5
+
+Both are built from the repository root as the Docker build context, so the local package source is copied into the container and installed via a Composer path repository. This means every test run tests the _current working tree_ of the package, not a published version.
+
+#### Configuration variants
+
+The `FLOW_CONTEXT` environment variable is passed into the docker compose environment via variable substitution, and Flow's hierarchical configuration loading picks up the corresponding `Settings.yaml` from the SUT:
+
+| Playwright tag | `FLOW_CONTEXT` | What is tested |
+|---|---|---|
+| `@default-context` | `Production/E2E-SUT` | No enforcement — 2FA is optional |
+| `@enforce-for-all` | `Production/E2E-SUT/EnforceForAll` | `enforceTwoFactorAuthentication: true` |
+| `@enforce-for-role` | `Production/E2E-SUT/EnforceForRole` | Enforcement scoped to `Neos.Neos:Administrator` |
+| `@enforce-for-provider` | `Production/E2E-SUT/EnforceForProvider` | Enforcement scoped to an authentication provider |
+| `@issuer-name-change` | `Production/E2E-SUT/IssuerNameChange` | Custom `issuerName` setting |
+
+#### Test isolation
+
+Each scenario starts with a clean state. An `AfterScenario` hook runs after every scenario to:
+
+1. Log the browser out via a POST to `/neos/logout`
+2. Delete all Neos users (`./flow user:delete --assume-yes '*'`)
+
+Deleting all users also cascades to their 2FA devices, so no separate cleanup step is needed. Users and devices are re-created by the Background steps at the start of each scenario.
+
+#### Design decisions
+
+**Gherkin / BDD over plain Playwright specs** — the feature files document the intended behaviour of each configuration variant at a level that is readable without knowing the implementation. The generated Playwright spec files (`.features-gen/`) are not committed; they are re-generated by `bddgen` before each test run.
+
+**UI-only device enrolment** — 2FA devices are enrolled through the browser UI (the backend module or the setup page) rather than a dedicated CLI command. This avoids coupling the tests to internal persistence details and exercises the same enrolment path a real user would take. The `deviceNameSecretMap` in `helpers/state.ts` carries TOTP secrets across steps within a scenario (e.g. from the enrolment step to the OTP entry step).
+
+**Sequential execution** — tests run with `workers: 1` and `fullyParallel: false` because all scenarios share a single running SUT container and a single database. Running them in parallel would cause interference between scenarios.
+
+**User creation via `docker exec`** — Neos user creation is done through the Flow CLI (`./flow user:create`) rather than the UI because the UI path is not part of what this package tests, and using the CLI is faster and more reliable for setup.
diff --git a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion
index 16ed82a..e573c51 100644
--- a/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion
+++ b/Resources/Private/Fusion/Integration/Controller/Backend/New.fusion
@@ -87,10 +87,18 @@ Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoF
attributes.aria-label={I18n.id('otp-placeholder').package('Sandstorm.NeosTwoFactorAuthentication')}
attributes.autocomplete="off"
/>
+
| {I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | +{I18n.id('module.index.list.header.username').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | {I18n.id('module.index.list.header.type').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | +{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | {I18n.id('module.index.list.header.creationDate').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} | |
|---|---|---|---|---|---|
| {props.factorAndPerson.user.name.fullName} ({props.factorAndPerson.secondFactor.account.accountIdentifier}) | {props.factorAndPerson.secondFactor.typeAsName} | +{props.factorAndPerson.secondFactor.name == null ? '-' : props.factorAndPerson.secondFactor.name} | {props.factorAndPerson.secondFactor.creationDate == null ? '-' : Date.format(props.factorAndPerson.secondFactor.creationDate, 'Y-m-d H:i')} |
-
@@ -71,7 +73,7 @@ prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry
{I18n.id('module.index.delete.cancel').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}
diff --git a/Resources/Private/Translations/de/Backend.xlf b/Resources/Private/Translations/de/Backend.xlf
index 9ba96b8..29881a1 100644
--- a/Resources/Private/Translations/de/Backend.xlf
+++ b/Resources/Private/Translations/de/Backend.xlf
@@ -15,14 +15,18 @@
|