Commit Graph

548 Commits

Author SHA1 Message Date
aminovfariz
c32a0f8c31 feat(helpdesk): Übergabe assignment workflow
- New statuses: under_review
- New DB fields: assigned_to, assigned_by, assigned_at, due_date (migration 012)
- HandoverNotificationService: emails on assign and review-request
- HandoverService: assign(), submitForReview(), resendNotification()
- Assign page: manager selects employee + due date, resend notification
- Create wizard: manager can assign during creation (step 1)
- Edit page: "Submit for review" button for assignee, assign link for manager
- List page: open/closed tabs, non-managers see only their assigned handovers
- Email templates: handover_assigned + handover_review_requested (de/en)
- i18n keys added for de and en

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:35:03 +02:00
aminovfariz
4f38911fce fix(docker): copy module web assets as real files in prod image
web_init uses cp -rp which copies symlinks as symlinks.
Since the nginx container doesn't have /var/www/modules/,
symlinks in web/modules/ break. Replace them with actual copies at build time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:50:45 +02:00
aminovfariz
0d078f233d fix: suppress open_basedir warning in favicon check, disable open_basedir in prod
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:07:47 +02:00
aminovfariz
ec596d35d4 fix(auth): extend stream-based HTTP to POST requests in OidcHttpGateway
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:14:19 +02:00
aminovfariz
4e51d9e882 fix(auth): use file_get_contents for OIDC GET requests to work around libcurl 8.14/OpenSSL 3.5 TLS fingerprint rejection by Microsoft
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:45:52 +02:00
aminovfariz
293807b92a chore: add .dockerignore for production image builds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:31:36 +02:00
aminovfariz
1b615a5e1c fix: update local docker and php config 2026-05-26 11:04:57 +02:00
aminovfariz
cb2f429e0e feat(helpdesk): add empty Dashboard page with sidebar nav item
Adds helpdesk/dashboard route, empty page/view, Overview nav group in the
sidebar, and Dashboard i18n keys (de + en). No content yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:01:35 +02:00
fs
873b75465e docs: important name change 2026-04-29 10:00:36 +02:00
fs
0a86d1b44b docs: Readme changes 2026-04-29 09:59:16 +02:00
fs
545bcc789b docs: replace ASCII umlaut fallbacks with proper äöüß across all .md
Most German docs were written with `ue`/`ae`/`oe`/`ss` ASCII fallbacks
(`fuer`, `aenderung`, `koennen`, `gemaess`) — relic from older toolchain
constraints. Replaced 237 occurrences across 38 .md files with proper
umlauts and ß so the docs read naturally.

Substitutions are constrained to plain prose: fenced code blocks
(```...```), inline code spans (`...`), markdown link targets `[..](..)`,
and HTML comments are preserved verbatim. Path references like
`docs/howto-erste-aenderung.md` keep their original (umlaut-replaced)
filenames — only the surrounding prose is normalised.

Tool: bin/fix-md-umlauts.py (idempotent — no-op on already-clean files).
Re-runnable if ASCII fallbacks creep back in via copy-paste.

Doc-link-check + doc-drift-check stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:57:22 +02:00
fs
2e73cb98b4 feat(console): add db:migrate CLI for idempotent core schema updates
Existing systems used to have no automatic mechanism for the
db/updates/*.sql idempotent updates after `git pull` — devs had to
remember which files were already applied and run new ones manually
with `mariadb < ...`. Mirror module:migrate to fix this:

- New `bin/console db:migrate` walks db/updates/*.sql in alphabetical
  order, skipping anything already recorded in the new core_migrations
  tracking table. Each file runs in its own transaction; failure
  rolls back so the file is retried on the next run.
- New `db:migrate --status` lists applied vs. pending files without
  running anything. Useful for ops debugging "schema seems stale"
  symptoms.
- module:sync now runs db:migrate as its first step, so the standard
  post-pull command stays a single invocation. README's 3-step setup
  is unchanged — module:sync now also covers core schema updates.

CoreMigrationRepository owns its own mysqli connection (using the
same DB credentials as MintyPHP\DB) and runs raw `$mysqli->query()`
instead of going through DB::query's prepared statement path —
MariaDB rejects some DDL (e.g. ALTER TABLE … ADD CONSTRAINT CHECK)
in the prepared protocol, which the existing 18 db/updates files
trip on. ModuleMigrationRepository is left untouched (no module
migration uses such DDL today and changing the vendor pattern is
out of scope).

End-to-end verified: against an existing DB the first run applies
all 19 idempotent files as no-ops and records them, second run
reports "all updates already applied". Against a freshly init'd
DB the same thing happens — no double work, no surprises. All
encrypted seed secrets keep decrypting because APP_CRYPTO_KEY
is unchanged.

Tests: tests/Console/CoreCommandsTest covers success/failure/status
output shapes against a FakeModuleRunner (mirror of the existing
ModuleCommandsTest pattern). 5 stale baseline entries in
phpstan-baseline removed (covered by the new @api annotation on
the test fixture class).

Docs: CLAUDE.md and docs/reference-cli-commands.md document the
new command + --status flag; docs/howto-fehlerbehebung.md gains
a "schema seems stale after git pull" section pointing at
db:migrate --status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:59:57 +02:00
fs
0c78dc4355 chore(setup): track config.php directly, slim README to setup checklist
config/config.php was gitignored and devs had to copy it from
config/config.php.example on every fresh clone. Since the bootstrap
config reads everything from .env via $envString() / $envInt() /
$envBool() defaults, there is nothing developer-specific in the file —
the cp step was dead ceremony.

- Remove config/config.php from .gitignore and track it directly
- Delete config/config.php.example
- Drop the example-existence + per-doc reference checks from
  bin/docs-drift-check.sh; add a legacy-pattern check that flags
  any new mention of the removed example file
- Update ConfigContractsTest to require config.php and forbid the
  example
- Clean stale references in CLAUDE.md, README.md, six docs files,
  and the core-guardrails skill

Same commit slims README down from 294 to 47 lines: one setup
checklist (now 3 commands instead of 4), the seeded login table,
the three quality-gate commands, and pointers to CLAUDE.md and
docs/index.md for everything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:37:45 +02:00
fs
f36b169f67 chore(seed): bake live test connections into init.sql + helpdesk migration
Fresh installs now come up with working dev test connections for
Microsoft SSO and Business Central without manual configuration:

- init.sql appends the live breadcrumb + icoreon tenants (idempotent
  by uuid), Felix as a 6th user with all five roles, the live
  departments, plus tenant_auth_microsoft (SSO enabled for
  breadcrumb), tenant_auth_ldap stubs, and the global Microsoft
  shared-app credentials. The MusterMandant demo seed is preserved
  as tenant 1.
- A new helpdesk migration 011 seeds the per-tenant BC connection
  rows in helpdesk_tenant_settings and the helpdesk.bc_* fallback
  settings — module-owned data stays in module migrations.
- .env.example sets APP_CRYPTO_KEY so the AES-256-GCM blobs in the
  seed (Microsoft client secret, BC basic auth passwords) actually
  decrypt out-of-the-box. Verified by decrypting all four blobs
  end-to-end through Crypto::decryptString.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:27:47 +02:00
fs
a5213977ca fix(helpdesk): drop stale require of removed app-flash partial
Commit 4890a28 folded templates/partials/app-flash.phtml into
app-toast-stack.phtml, which the base layout now mounts itself. 14
helpdesk views still required the deleted partial and crashed every
helpdesk page with a 500 (Failed opening required app-flash.phtml).
Remove the redundant requires; the unified toast stack continues to
render flash messages via the layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:59:51 +02:00
fs
6146b0bd00 test(architecture): catch missing container bindings statically
Codifies the rule that every class referenced via `$c->get(X::class)`
inside a container registrar must itself be bound via
`$container->set(X::class, ...)` somewhere — in the same registrar,
in core/App/registerContainer.php, or in a module container
registrar.

Catches the bug pattern from cd2c9c5: a registrar's factory closure
pulled UserSettingsGateway via $c->get() but no matching ->set() ever
ran. Unit tests with mocked dependencies passed; the failure only
surfaced at runtime ("Service not bound: ...") when the affected
admin/settings/user-lifecycle page tried to construct the dashboard
service against the live container.

Implementation is purely static — token / regex parsing of registrar
files plus class_exists() / interface_exists() gating to avoid false
positives on non-class identifiers. No factory invocation, so the
test does not require DB / session / HTTP and is fast.

Verified by reverting the cd2c9c5 binding line in the working tree
and running the test — it produces the exact "UserSettingsGateway
referenced in core/App/Container/Registrars/UserRegistrar.php"
finding. Restored after the dry-run.

Allowlist starts with two framework-bootstrap classes (AppContainer,
ModuleRegistry) that are bound outside the registrar mechanism.
Each future addition needs an inline justification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:54:21 +02:00
fs
ca13fcd4a3 chore(i18n): drop user-lifecycle translation keys orphaned by cockpit cleanup
Seven translation keys were orphaned by the recent user-lifecycle
cockpit work (commits 144d841, cc2cf3a, 157eb18) and now have zero
callers in pages/, modules/, or core/:

* "Run lifecycle now" — was the <details>-summary title
* "Run user lifecycle now" — was the <details>-button label
* "This runs the lifecycle policy immediately and may deactivate or
  delete users." — was the <details>-blockquote warning
* "User lifecycle logs" — was the embedded panel's title
* "Purge user lifecycle logs" — was the panel's purge-button label
* "Purge entries older than 365 days?" — was the panel's purge confirm
* "Lifecycle audit entry not found" — was the deleted view($id).php
  flash key

The aside-actions partial uses different, still-active keys
("Run policy now", "Purge logs", "Run user lifecycle now?") so no
visible string lost. Removed from all four i18n files
(core + audit-module, de + en) so translation parity stays balanced
and the architecture-test "i18n parity" check stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:44:17 +02:00
fs
cd2c9c5741 fix(user-lifecycle): bind UserSettingsGateway in container
Previous commit 06118c1 wired UserLifecyclePolicyDashboardService to
$c->get(UserSettingsGateway::class) without ensuring the gateway
itself was a container service. The gateway is built by
UserServicesFactory::createUserSettingsGateway() and was previously
only obtained transitively as a constructor argument of other
factory-built services — never resolved through the container
directly.

Result at runtime: 500 with "Service not bound:
MintyPHP\\Service\\User\\UserSettingsGateway" the first time the
user-lifecycle settings page tried to construct the dashboard
service.

Adds the missing binding in UserRegistrar — same factory delegation
the file uses for UserAccountService, UserAssignmentService, etc.

Unit tests didn't catch this because they construct the dashboard
service with mocked dependencies; only the live page touched the
real container path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:30:34 +02:00
fs
06118c1b26 fix(user-lifecycle): track last-run state in core, not in audit log
The "Last run" KPI tile stayed empty after a manual policy run, even
though the run completed successfully. Two distinct bugs were
involved:

1. The dashboard read latestSystemRun() from the audit log filtered by
   trigger_type='system'. UserLifecycleService::run() never sets that
   value — it uses 'manual' for actor-triggered runs and 'cron' for
   scheduled ones. The query never matched anything.

2. Even with the right trigger_type, the audit log only writes per-user
   entries (logDeactivate / logDelete / logDeleteFailure). A run that
   processes zero users — including most cron ticks on a healthy
   tenant — leaves no trace, so the tile would still show "—" after a
   correct execution.

Both bugs share one root cause: run-trigger state was being inferred
from audit-log details, but those are two semantically different
things. Audit log answers "what did the run do?". A "last run" tile
answers "did the run happen?".

This commit moves run-trigger state to the core settings table and
keeps the audit log strictly for per-user events:

* Two new keys in core/Service/Settings/SettingKeys —
  USER_LIFECYCLE_LAST_RUN_AT_KEY and USER_LIFECYCLE_LAST_RUN_STATUS_KEY.
* SettingsUserLifecycleGateway gains recordLastRun() and getLastRun().
  UserSettingsGateway exposes them as recordLifecycleLastRun() /
  getLifecycleLastRun() so UserLifecycleService can call through its
  existing dependency without growing its constructor.
* UserLifecycleService::run() writes both keys in finally — every time
  the lock was acquired, regardless of whether any user was processed
  and regardless of whether the run finished cleanly. Status reflects
  $result['ok'] ('success' / 'failed').
* UserLifecyclePolicyDashboardService gains a lastRun() reader. Action
  page now sources the KPI tile from this core service instead of the
  audit interface — so the tile works even when the audit module is
  disabled.
* The audit-side lastRun() / latestSystemRun() / their tests are
  removed (YAGNI). Phase 4 (activity feed) can rebuild from the audit
  filter grid without a special method.

Behaviorally: a no-op run now records "Last run: just now · ✓ Success"
in the cockpit, exactly as expected.

All six quality gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:07:57 +02:00
fs
97d09fbd94 fix(user-lifecycle): aside actions use normal size + repair policy ref link
Two small follow-ups to the cockpit phase-1 commit cc2cf3a:

* Drop the 'small' class from the three aside Quick Actions. The
  aside has plenty of room and the small variant felt cramped next
  to the page title — normal-size buttons match the visual weight
  of the form's Save button above.
* Repair the Policy reference link. It pointed at a raw markdown
  path (docs/reference-benutzer-lifecycle-policy.md) which 404s
  because the static-file route never existed. The codebase has a
  Markdown viewer at admin/docs/<slug> backed by DocsCatalogService;
  the correct slug is 'reference-benutzer-lifecycle-policy'. Link
  is now also gated by ABILITY_ADMIN_DOCS_VIEW so users without
  docs permission don't see (and 403 on) it. target=_blank dropped
  because it's an in-app route now, not an external file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:43:28 +02:00
fs
157eb18610 refactor(user-lifecycle): drop redundant titles and duplicate run-now button
Cleans up two leftover redundancies after the Phase-1 cockpit
foundation landed in cc2cf3a:

* The audit-list panel embedded inside the settings page rendered
  its own "User lifecycle logs" titlebar — a redundant heading
  below the page-level titlebar that already says exactly that.
  The embedded panel now renders the filter toolbar + grid
  directly. Page-level title carries the context.
* The Run-Now action existed twice — inline in a <details>-card
  inside the form and again in the aside Quick-Actions list. The
  inline version is gone; aside is the single discoverable home
  for policy-level danger actions, consistent with how Phase 1
  introduced Purge logs there too.
* The orphan $lastRunSummary string in the action and view stays
  removed accordingly. KPI tile "Last run" still carries the
  relative-time + status hint, so no information is lost — just
  surfaced once instead of twice.

Three architecture-test lists updated to match the panel's new
shape, each with an inline comment so future readers see why the
panel is intentionally absent:
* DetailActionPolicyContractFiles.migratedConfirmFiles drops the
  user-lifecycle settings view (its danger action delegates to the
  aside-actions partial, already in this list).
* ListUiSharedPartialsContractTest.purgeTitlebarTemplateFiles drops
  the panel (no titlebar of its own anymore).
* ListTitlebarContractFiles.titlebarTemplateFiles drops the panel
  for the same reason.

All six quality gates green; behaviour-identical to a user with the
required permission (purge + run-now both still available, just
sourced from the aside).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:38:15 +02:00
fs
cc2cf3a254 feat(user-lifecycle): cockpit foundation — KPI row + aside quick actions
Phase 1 of the Stripe-style policy-cockpit redesign for the user-lifecycle
settings page. Pure server-rendering — no async, no JS components, no
sparklines yet (those land in later phases).

Adds a four-tile KPI row above the configuration form (Last run,
Deactivated/30d, Deleted/30d, Pending deletion/7d), populates the
previously empty aside with three quick actions (Run policy now,
Purge logs, Policy reference link), and surfaces a relative-time +
status hint under the existing Run-Now collapsible.

Module-isolation is preserved through a new read-side contract:

* core/Service/Audit/UserLifecycleAuditDashboardInterface — read-only
  pendant to the existing write-side UserLifecycleAuditInterface.
  Methods: lastRun(), summaryByAction(int days), countActionInWindow(...).
* core/Service/Audit/NullUserLifecycleAuditDashboard — fail-closed
  default when the audit module is disabled. KPI tiles 1-3 then
  render "—"; tile 4 (pending deletion) keeps working because it
  lives in the core domain.
* modules/audit/.../Service/UserLifecycleAuditDashboardService — the
  module's implementation; reads through the existing
  UserLifecycleAuditRepository (extended with three new aggregation
  queries: lastRun, countByActionStatusSinceTimestamp, countSinceTimestamp).
* AuditContainerRegistrar binds the interface to the module impl;
  registerContainer.php registers the Null fallback before module
  bindings, mirroring how the write-side audit interface is wired.

The new core service UserLifecyclePolicyDashboardService computes
the pending-deletion-window count from the users table directly
(no audit dependency) — defensive when both policy days are 0
(returns 0 rather than running an unbounded query).

New shared template partial templates/partials/app-kpi-row.phtml is
generic — accepts a $kpiTiles array of {label, count, icon, iconTone,
href, tooltip} and reuses the existing app-tile primitive. Other
settings pages can pick it up without ceremony.

Includes:
* PHPUnit tests for both new services (happy path + Null-fallback +
  policy-disabled edge cases).
* AuditModuleIsolationContractTest allowlist extended for the new
  interface and module service.
* 14 new translation keys in both default_de.json and default_en.json
  (i18n parity verified).

All six quality gates green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:36:28 +02:00
fs
004c25ac4b refactor(user-lifecycle-audit): drop unused filterOptions / listFilterOptions
Follow-up to commits 144d841 (Embed user lifecycle audit into settings
page) + 8c07c2c (Revert "fix(user-lifecycle-panel): restore
filter-options population").

The audit page move turned the user-lifecycle list-page into a slot
template included by pages/admin/settings/user-lifecycle. Slot
templates run during the view phase, where MintyPHP forbids DB calls
("Database can only be used in MintyPHP action", DB.php). The
populated actor / action / status / trigger filter dropdowns —
previously fed by UserLifecycleAuditService::filterOptions() during
the action phase of the now-deleted index().php — therefore had to
go away and were not replaced. The service method and its
underlying UserLifecycleAuditRepository::listFilterOptions() became
dead code in the same commit but were kept; this cleans them up.

Removed:
* UserLifecycleAuditService::filterOptions(int) — single caller was
  the deleted index() action.
* UserLifecycleAuditRepository::listFilterOptions(int) — single
  caller was the service method above.
* Stale phpstan-baseline.neon entry for the service method.
* Now-unused RepositoryArrayHelper import in the repository.

Sister services (Api / System / Import) still use their own
filterOptions() — their list-pages remain action-driven and can
populate dropdowns from the DB. The constant FILTER_OPTIONS_LIMIT_MAX
in UserLifecycleAuditRepository is kept; listPaged() reuses it as a
generic upper bound for multi-value filter inputs.

The reduced-fidelity actor filter in the settings panel (URL-pinned
IDs only, no DB-side enumeration) is the architecturally correct
trade-off for the slot-based isolation. A future provider mechanism
on the slot manifest could restore richer filter population without
breaking module isolation, but that is out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:28:12 +02:00
fs
8c07c2c93a Revert "fix(user-lifecycle-panel): restore filter-options population"
This reverts commit a1c6d62d44.
2026-04-26 18:25:34 +02:00
fs
a1c6d62d44 fix(user-lifecycle-panel): restore filter-options population
Follow-up to commit 144d841 (Embed user lifecycle audit into settings
page). The original move from a standalone audit page to a settings
panel silently dropped UserLifecycleAuditService::filterOptions()
from the panel template. The service method was kept (still defined
in UserLifecycleAuditService.php) but had zero callers — dead code,
and three concrete UX/data regressions:

* Actor filter dropdown is empty: previously populated with
  display_name + email + (deleted)-marker for every actor that
  appeared in lifecycle events; after the move only actor IDs
  already pinned in the URL via ?actor_user_ids= are listed, with
  bare "User #N" labels instead of human-readable names.
* DB-only enum values are no longer surfaced: action/status/trigger
  filter dropdowns are populated only from the PHP enum cases.
  Migration drift values present in the DB but absent from the enum
  silently disappear from the filter UI.
* Active-actor-fallback lost the (deleted) suffix. An actor ID in
  the URL with no matching DB row used to be labeled "User #N
  (deleted)" — now just "User #N", losing the lifecycle hint.

Restores the three filter-options merge loops verbatim from the
pre-144d841 implementation: defensive enum-merge for actions /
statuses / triggers, full actor enumeration from filterOptions['actors']
with display_name/email/exists handling, and the (deleted) suffix
fallback. UserLifecycleAuditService::filterOptions() is once again
consumed; sister services (Api/System/Import) keep their existing
usage unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:21:27 +02:00
fs
144d8410a4 Embed user lifecycle audit into settings page 2026-04-26 18:03:42 +02:00
fs
01f5265eff test(architecture): drop stale actionFragmentContext mentions
Cleanup follow-up to commit 9ec10f5, which removed the unused
actionFragmentContext aggregator and its building block. Two
architecture tests still mentioned the removed aggregator in their
allowlists and recognizer regexes — patterns that now match an empty
set, harmless but misleading.

* ActionContextCsrfPairingContractTest: drop 'actionFragmentContext'
  from the AGGREGATORS constant, update the docblock to list only the
  two remaining aggregators, and rewrite the GET-only-allowlist
  comment to no longer reference the fragment-specific case (the
  guard itself stays — any future GET-only aggregator caller would
  still hit it).
* DetailDrawerFragmentContractTest: drop the third alternative from
  the aggregator-recognizer regex inside extractTopLevelAbility, and
  trim two comments accordingly.

The historical documentation in ActionContextHelperContractTest is
deliberately kept — those comments explain to future readers why the
test only freezes 5 building blocks and 2 aggregators (instead of
the original 6/3) and why the CSRF-warning expectation is 2 instead
of 3. That is contextual documentation, not stale references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:47:36 +02:00
fs
9ec10f5c02 refactor(action-context): remove unused fragment aggregator + building block
actionFragmentContext was built spec-driven in step 1 to handle the
anticipated drawer-fragment pattern: authorize + finder → status DTO.
The action-context rollout (steps 2-11) found that none of the three
real drawer fragments (users/view-fragment, addressbook/view-fragment,
helpdesk/ticket-fragment) match that pattern — all three delegate to
domain services that own their own status models. Step 11 declared
them structural exceptions; the helper code remained unused.

This commit removes the dead spec:
* core/Support/helpers/action_context.php drops actionFragmentContext
  (~9 LOC) and its building block actionFragmentResolveOrStatus
  (~30 LOC) plus their docblocks. The MUST-call-actionRequireCsrf
  warning, present in three aggregator docblocks before, now appears
  twice (one per remaining aggregator).
* tests/Support/Helpers/ActionContextHelperTest.php drops the six
  unit tests that exercised these functions (~83 LOC).
* tests/Architecture/ActionContextHelperContractTest.php drops the
  fragment building block from the buildingBlocks() data provider
  (5 entries instead of 6) and removes testFragmentResolveReturnDocblockIsFrozen.
  The CSRF-warning expectation is updated from 3 to 2 with a code
  comment explaining the rollback.

Verified:
* No production caller exists in pages/ or modules/.
* All 9 aggregator callers (5 actionEditContext + 4 actionCreateContext)
  remain unchanged.
* ActionContextCsrfPairingContractTest and DetailDrawerFragmentContractTest
  are deliberately left untouched: their allowlists/recognizer regexes
  still mention actionFragmentContext, but the patterns now match an
  empty set — harmless dead text. Documented as open items in the run
  report; future cleanup is optional and orthogonal to this removal.
* QGs all green (PHPUnit 2088 tests, PHPStan level 5, CS-fixer 0 diffs).

Net: 3 files, +31/-198 LOC, behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:34:53 +02:00
fs
95723cebd9 test(architecture): enforce defensive reads in shared form partials
Codifies the convention that shared form partials (pages/admin/*/_form.phtml,
modules/**/_form.phtml) either guard variable reads with ?? / isset() / array
access checks, or have every consuming view template define the variable
before requiring the partial.

The convention was already followed by 6 of 7 partials in the codebase. The
seventh (tenants/_form.phtml) regressed silently when a per-theme logo block
introduced bare $canUpdateTenant reads in commit 6e3fc63c — fixed in commit
e29e6c3. This test catches that exact bug shape and any future variant.

Implementation uses token_get_all (no full PHP parser, no expression
evaluation). For each partial it identifies variable references that are not
locally defined or guarded, then walks the consuming templates discovered via
require statements and verifies each variable is present before the require.
On detection of statically unrecognizable constructs (extract(),
dynamic require paths) the test fails loudly with a "review manually" hint
rather than silently passing.

Verified by reverting commit e29e6c3 in the working tree and running the test
— it produces the exact $canUpdateTenant / line 223 finding that prompted the
original fix. Restored after the dry-run.

ALLOWLIST stays empty today. Future legitimate exceptions go in the test
header with per-entry justification, mirroring DetailDrawerFragmentContractTest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:19:14 +02:00
fs
1bd4607b66 refactor(creates-batch): migrate roles/permissions/tenants-create (step 10)
Cluster-7 batch-replay of the departments-create pilot (step 9). All
three remaining create actions follow the same shape with minor
domain-specific variations.

Each migration touches one action and one policy:

* roles-create + RoleAuthorizationPolicy::authorizeAdminRolesCreate —
  policy previously returned bare allow() with no capabilities; now
  emits ['can_view_page' => true]. Action passes viewAuthFlags: [].
* permissions-create + PermissionAuthorizationPolicy::authorizeAdminPermissionsCreate —
  same pattern as roles-create.
* tenants-create + TenantAuthorizationPolicy::authorizeAdminTenantsCreate —
  policy already emitted can_manage_sso + can_manage_custom_fields;
  can_view_page is added as the first capability. Action passes
  viewAuthFlags: ['can_manage_sso', 'can_manage_custom_fields'] and
  materializes both booleans from the aggregator capabilities.

All three policy updates are tautological — every actor that survives
the deny() branches in each policy can by definition see the page.
View, Create, and EditContext now share a consistent capability shape
across all four core master-data domains (departments, roles,
permissions, tenants).

Three drift decisions reproduced:
* notFoundFlashScopeKey is N/A (no model lookup in create flows).
* t() consistency: Flash::success('Role created' / 'Permission created'
  / 'Tenant created') now flow through t().
* Defensive scope consumption: $canManageAllTenants reads
  $tenantScope['scope'] === 'all' as a resilient hook even where the
  policy emits no manage-all flag (roles/permissions are global,
  tenants-create has no filter logic). Inline comments document the
  intentional non-consumption of $tenantScope['ids'].

Two contract-test pattern updates (AuthzAdminMasterDataContractTest +
AuthzAdminTenantsContractTest) shift the assertion targets from
AuthorizationService::class to actionCreateContext( — semantically
equivalent because the aggregator wraps the same authorize call
internally.

ActionContextCsrfPairingContractTest now covers nine callers
(five edits + four creates) and stays green. Helper file
core/Support/helpers/action_context.php is 0-diff for the seventh
consecutive migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:00:05 +02:00
fs
d6be536fbf refactor(departments-create): migrate to actionCreateContext (step 9)
First production use of actionCreateContext — the second aggregator
introduced in step 1 and unit-tested at the building-block level, but
not yet exercised against a real caller. Helper file stays 0-diff for
the sixth consecutive migration.

The migration uncovers one real API gap and resolves it at the policy
layer rather than at the helper:

* actionCreateContext always calls actionEnforceCanViewPage. The
  Departments create-decision was the only Departments authorize
  branch that did not emit can_view_page (View and EditContext both
  did). Adding 'can_view_page' => true to the create-capabilities
  map is tautological — every actor that survives the deny() guards
  at lines 69-70 and 75-76 can by definition see the page. No new
  forbidden path is created. View, Create, and EditContext now share
  the same capability shape.

Three drift decisions reproduced where applicable:
* notFoundFlashScopeKey is N/A (no model lookup in create flow).
* t() consistency: all three Flash::success('Department created', …)
  calls now flow through t().
* Defensive scope consumption: $canManageAllTenants reads
  $tenantScope['scope'] === 'all', mirroring the edit-action pattern.
  The GET tenant filter rewrites from is_array($allowedTenantIds) to
  the three-way scope-tuple form.

AuthzAdminMasterDataContractTest gets a single-line assertion update
(AuthorizationService::class → actionCreateContext() pattern). The
aggregator wraps the same authorize call internally, so this is a
pattern-rename, not a semantic shift.

ActionContextCsrfPairingContractTest now covers six callers (five
edits + departments-create) and stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:44:09 +02:00
fs
480b57ef04 refactor(users-edit): migrate to actionEditContext (step 6)
Cluster-2 pilot — first migration of a standard edit action whose
tenant-scope semantics use null = "manage all" (instead of the
boolean-flag pattern in cluster 1). The CONTEXT-stage vorspiel
collapses into one declarative actionEditContext call; everything
below the vorspiel (conditional audit, custom fields, security
artifacts, two-level submit-authorize, mergeTenantIdsPreservingOutOfScope,
post-save theme/locale/session hooks) stays callsite — domain logic.

Confirms the analyst hypothesis: actionEditContext +
tenantScopeFlagKey:'can_manage_tenants' is enough — no helper
extension. The override key was built in step 1, unit-tested at the
building-block level, and now production-validated.

Two callsite tenant-filter rewrites (GET line 98-105, POST line
190-208) replace is_array($allowedTenantIds) with
$tenantScope['scope']/$tenantScope['ids'] discrimination.
mergeTenantIdsPreservingOutOfScope still receives a list<int> — only
the argument source shifts; the function itself is unchanged.

Three drift decisions reproduced: notFoundFlashScopeKey:'user_not_found',
t() consistency on Flash::success('User updated'), defensive
$canManageAllTenants = $tenantScope['scope'] === 'all'. The legacy
$canManageTenants capability boolean stays alongside (it still gates
strict-mode fallback — both variables now coexist by design).

DetailDrawerFragmentContractTest gets an additive recognizer for
actionEditContext / actionCreateContext / actionFragmentContext
ability-key extraction. Without it the test couldn't see the
aggregator-mediated authorize call in users-edit, so the auth-parity
check against users/view-fragment would regress. Pure addition; the
legacy direct-authorize() regex path is untouched.

ActionContextCsrfPairingContractTest now covers five callers
(departments, tenants, roles, permissions, users) and stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:10:08 +02:00
fs
a038d0921b refactor(permissions-edit): migrate to actionEditContext (step 5)
Cluster-3a batch-replay of the roles-edit pilot (step 4). Same shape
as roles-edit with three domain-specific deltas:

* Integer ID instead of UUID — passed to the aggregator as
  (string) $id; the lookup itself stays integer-keyed via
  PermissionService::find().
* Extra authorize context key target_is_system — pre-computed from
  the loaded permission and threaded through both the CONTEXT and
  SUBMIT authorize calls.
* Domain renames (can_update_permission / can_delete_permission,
  permission_not_found scope-key, ABILITY_ADMIN_PERMISSIONS_*).

Confirms the cluster-3a pattern: forbiddenStrategy:'deny' produces
identical Guard::deny() semantics across actions; the helper file
stays 0-diff for the second cluster-3a action; PermissionService
warmup absence is preserved (cluster 3 does not need it).

Three drift decisions reproduced verbatim: notFoundFlashScopeKey,
t() consistency on Flash::success('Permission updated'), defensive
$canManageAllPermissions = $tenantScope['scope'] === 'all'.

ActionContextCsrfPairingContractTest now covers four callers
(departments, tenants, roles, permissions) and stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:49:04 +02:00
fs
1974aba6c2 refactor(roles-edit): migrate to actionEditContext (step 4)
Third pilot of the cluster rollout — first action that exercises
forbiddenStrategy:'deny' in production. The CONTEXT-stage vorspiel
collapses into one declarative actionEditContext call; the SUBMIT
branch keeps its explicit Guard::deny() (Two-Level-Authorize).

Confirms the analyst hypothesis: actionEditContext with
forbiddenStrategy:'deny' is enough — no helper extension needed.
Helper file stays 0-diff. The 'deny' path was built in step 1 and
verified by testAuthorizeAndExtractCapabilitiesUsesGuardDenyStrategy,
but only now proven against a real production caller.

Three drift decisions from steps 2/3 reproduced:
* notFoundFlashScopeKey: 'role_not_found' to preserve the existing
  Flash dedup-scope-key.
* t() consistency: both Flash::success('Role updated', …) calls now
  flow through t() — German users see fully translated messages.
* Defensive scope consumption: $canManageAllRoles reads
  $tenantScope['scope'] === 'all'. Roles are global, so
  $tenantScope['ids'] is intentionally not consumed; an inline
  comment documents that.

Roles-specific: PermissionService-warmup absence is preserved (Roles
don't need it — Departments/Tenants do, but cross-cluster consistency
is not a goal here, behavioral identity is).

ActionContextCsrfPairingContractTest now covers three callers
(departments-edit, tenants-edit, roles-edit) and stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:40:48 +02:00
fs
dffc4db9a2 fix(routing): prevent login redirect loops on alias targets 2026-04-26 10:20:28 +02:00
fs
d0544d3428 refactor(tenants-edit): migrate to actionEditContext (step 3)
Second pilot of the cluster rollout. Tenants-edit follows the same
pattern as departments-edit (step 2): the CONTEXT-stage vorspiel
collapses into one declarative actionEditContext call, the POST branch
(CSRF → SUBMIT-authorize → can_update gate → service call → PRG) stays
callsite-specific.

Confirms the analyst hypothesis from this run: actionEditContext is
strong enough for a second standard edit action without any further
API extension. The notFoundFlashScopeKey arg added during step 2 is
the only hook needed; helpers stay 0-diff.

The three drift decisions from step 2 are reproduced verbatim:
* notFoundFlashScopeKey: 'tenant_not_found' to preserve the existing
  Flash dedup-scope-key.
* t() consistency: both Flash::success('Tenant updated', …) calls now
  flow through t(), so German users see fully translated success
  messages rather than a German/English mix.
* Defensive scope consumption: $canManageAllTenants reads
  $tenantScope['scope'] === 'all'. Tenants-edit has no tenant-scope
  filtering of its own (the action edits tenants themselves), so
  $tenantScope['ids'] is intentionally not consumed; an inline
  comment documents that.

ActionContextCsrfPairingContractTest now covers two callers
(departments-edit, tenants-edit) and stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:05:58 +02:00
fs
e29e6c3136 fix(tenants-create): define $canUpdateTenant before form partial
The shared _form.phtml partial reads $canUpdateTenant for the per-theme
logo upload blocks added in 6e3fc63c, but create(default).phtml never
defined it — every visit to admin/tenants/create raised an undefined-
variable warning and a 500.

In create-mode the actor has full edit authority over the form they are
filling in (the create-authorize gate has already passed), so set
$canUpdateTenant = true before requiring _form.phtml. Mirrors the value
that edit($id).php derives from the EDIT_CONTEXT capabilities map.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:05:42 +02:00
fs
3742ad19c2 chore: enforce nginx-only setup and remove .htaccess 2026-04-26 09:50:40 +02:00
fs
5378209fed refactor(departments-edit): migrate to actionEditContext (step 2)
Pilot migration of pages/admin/departments/edit($id).php onto the
actionEditContext aggregator introduced in step 1. The CONTEXT-stage
vorspiel (lookup → authorize → tenant-scope → can_view_page → viewAuth)
collapses into a single declarative call; the POST branch (CSRF →
SUBMIT-authorize → can_update gate → service call → PRG) stays
callsite-specific as planned.

Three deliberate touches beyond a 1:1 lift:

* Additive aggregator extension: actionEditContext gains an optional
  notFoundFlashScopeKey arg so the dedup scope-key 'department_not_found'
  is preserved without widening the frozen actionResolveModelOrFail
  building-block signature. Pattern is documented as the
  forward-compatibility mechanism for future cluster migrations.
* t() consistency: the not-found message now flows through t() via the
  aggregator. To avoid a partial-translation mix, Flash::success calls
  for 'Department updated' (×2) are also wrapped — German users now see
  fully translated messages instead of a German/English mix.
* Defensive scope consumption: the action now consults
  $tenantScope['scope'] before falling through to the strict-mode
  fallback. The Departments policy never emits can_manage_all_tenants
  today (so behavior is identical), but the action is now resilient to
  future policies that might.

New ActionContextCsrfPairingContractTest enforces actionRequireCsrf()
before any POST body access for actions that use the aggregators —
preventing CSRF-pairing regressions during the cluster-wide rollout
(step 3). The step-1 testNoProductionCallSitesYet guard is removed,
since departments-edit is now the first legitimate caller; the new
pairing test takes over its protective role with a more substantive
guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:52:27 +02:00
fs
3207d71244 harden(logout): enforce POST+CSRF and remove GET logout triggers 2026-04-25 23:35:03 +02:00
fs
a24dc0c235 harden(rate-limiter): fail secure login scopes with cache fallback 2026-04-25 23:17:46 +02:00
fs
f9c09f8746 refactor(actions): introduce action-context helpers (step 1)
Six orthogonal building blocks plus three cluster aggregators in
core/Support/helpers/action_context.php, preparing consolidation of the
~40-60 line vorspiel duplicated across edit/create/view-fragment actions.

Step 1 of a planned 3-step rollout: no production call sites yet —
pages/ and modules/ are untouched. Architecture tests freeze the
building-block signatures and verify drawer-fragment AuthZ parity.

GR-SEC-009 is structurally enforced via the actionDeriveTenantScope
return shape (PHPStan array{scope: 'all'|'list', ids: list<int>});
'all' is unreachable without an explicit can_manage_all_tenants flag.
Aggregator docblocks carry a mandatory CSRF-pairing warning per
GR-SEC-001; actionBuildViewAuth flags the e()-escape obligation per
GR-SEC-010.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:08:52 +02:00
fs
61c1d18e95 harden(api-login): remove account-state leak and equalize failure timing 2026-04-25 21:37:09 +02:00
fs
e775ed2800 refactor(login): Stripe-style split layout + multi-step polish
Reworks the auth flow from a single centered card into a two-pane
layout — form on the left, tenant brand on the right — and tightens the
multi-step login UX along the way. Major changes:

LAYOUT
- templates/login.phtml splits into a flex-column body so the footer
  spans both panes at the bottom instead of getting clipped under main.
- New .login-form-pane and .login-brand-pane on a 1fr/1fr grid above
  768px; mobile stacks brand on top as a slim band, form below.
- Brand pane carries a soft halo + dot grid + tinted base, all derived
  from --app-primary so it tracks the tenant accent automatically.
- Login card gets a Stripe-style hairline border + soft shadow, no
  Pico article > header sectioning band, h1 in semibold + tracking-tight.
- The "body > main" global padding is overridden for login so the brand
  panel reaches the very top + bottom edges of its column.

OS THEME FALLBACK
- New appExplicitTheme() returns the user/tenant theme or empty string,
  used by login.phtml + error.phtml to OMIT data-theme entirely when no
  preference exists. CSS prefers-color-scheme media query then drives
  the theme — DB stays the source of truth, no browser-side persistence.

MULTI-STEP UX
- Heading is stage-aware: "Login" / "Select tenant" / "Login to {tenant}".
  Drops the redundant "Login credentials" subtitle.
- Stage 3 gets an identity pill (icon + email + compact "use different
  email" button) replacing the old tenant-context block, so the user
  always sees which account they're signing in with regardless of
  multi-tenant status.
- Stage 2 tenant selection drops avatars + initials — just radio + name
  with text-overflow ellipsis for long tenant names.
- Tightens primary CTA: full-width on every stage incl. <p>-wrapped
  buttons. autofocus moves to the right input per stage (ldap_username
  / password).

NOTICE / HELP LINKS
- The placeholder "Problems logging in" link is gone (it used to point
  at the imprint route — misleading). show_support wired through 6
  auth pages and the partial removed; architecture tests adjusted.
- Help-links centered with bullet separators between items, hairline
  border-top so they read as secondary navigation under the main CTA.
- The "Encrypted / HTTPS/TLS 1.2+" trust badge at the card bottom is
  removed — modern users assume HTTPS, and the badge added noise.

DEAD CODE
- $authLogoHref, $selectedTenantAvatarUrl, $selectedTenantInitial,
  $hasSelectedTenantAvatar, $canSwitchTenant — unused after the
  identity-pill / brand-pane move, removed from all 6 auth pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:16:26 +02:00
fs
4890a280fb refactor(ui): unify notice + toast styling, polish inline notices
Notices used to be styled only inside the toast stack — every inline
.notice on the login page, auth pages and admin edit screens fell back
to browser defaults and looked unstyled. The Stripe-style toast redesign
(icon pill + neutral text + soft card surface) now lives on the base
.notice rule, and .app-toast-stack adds the slide-in animation, soft
shadow, dismiss button and progress bar on top.

Inline notices auto-render the variant icon via a ::before pseudo using
the Bootstrap Icons codepoints, so all 30+ existing call sites get the
new look without markup changes. Toasts opt out of ::before via
:has(> .notice-icon) and keep their explicit icon span.

The previous app-flash.phtml partial is folded into app-toast-stack.phtml
(both create the same .app-toast-stack container — having both mounted
made two stacks fight for the same fixed corner). default/login/page
templates now mount the unified partial; the architecture contract is
updated to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:32:09 +02:00
fs
2429588256 refactor(ui): move toast stack chrome to a server-rendered partial
Mirrors the detail-drawer move: the .app-toast-stack container is no
longer lazy-created in JS on first toast, instead it lives in
templates/partials/app-toast-stack.phtml and is mounted once in
default.phtml alongside the confirm dialog. The aria-live attribute now
sits in the SSR markup so screen readers register the live region from
page load, not from the first toast.

getToastStack() drops to a plain querySelector and warns via warnOnce
when the partial is missing instead of constructing a fallback container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:13:55 +02:00
fs
4d1bd13a3b refactor(ui): move detail drawer chrome to a server-rendered partial
The drawer chrome used to be built in JS via a 50-line innerHTML string
and needed every caller to plumb through a labels object sourced from
per-page PHP config. That diverged from the codebase convention — the
confirm dialog, search dialog and session warning are all PHP partials
mounted once in templates/default.phtml. The drawer now follows the
same pattern: templates/partials/app-detail-drawer.phtml renders the
markup with t() labels, default.phtml mounts it inside the logged-in
shell, and the JS only attaches behavior via the [data-detail-drawer]
selector.

Knock-on cleanup: ensureDrawerElement and resolveLabels disappear from
the JS, all three initDetailDrawer callers (admin/users, address book,
helpdesk debitor) drop their labels parameter, and the matching dead
'drawerClose'/'drawerPrev'/… entries leave the per-page PHP grid configs.
The drawer also gains a clean fallback when the partial is missing
(console warn + null return), so logged-out edges can't crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:06:06 +02:00
fs
b205ba1312 refactor(sidebar): drop per-group rainbow, add subtle active background
Removes the nine hardcoded HSL overrides that gave each admin and
helpdesk nav group its own icon color and standardises on the tenant
accent (--app-primary) for every group, since the variable already had
that as its fallback. The active link state now also gets a subtle
background tint via a new --app-sidebar-active-bg token (12% accent
mixed with transparent) so the selected entry reads as more than just a
border-left and color shift; hover keeps a lighter 6% tint so it stays
distinct from active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 19:05:55 +02:00
fs
05fa149273 refactor(settings): order hub tiles along an onboarding flow
Reorders the nine settings tiles so a fresh admin walks them top-down:
app basics → look & feel → outbound mail → user policies → integrations
→ observability. Drops the previous order which mixed user policies with
audit/telemetry before integrations and put branding last. Alphabetical
sorting was considered and rejected because it would render different
first tiles per locale (DE vs EN sort to different positions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:41:06 +02:00
fs
9d74c8830a refactor(settings/branding): two side-by-side upload cards via barrier forms
Branding now follows the tenant-logos pattern: four hidden barrier forms
(logo upload + delete, favicon upload + delete) declared once at the top
and two file-upload partials placed side-by-side inside a grid, hooked
to their forms via the HTML5 form="..." attribute. The redundant
preview wrapper above each upload is gone — the file-upload partial
already renders the current image, replace/delete buttons and metadata.
The favicon hint moves from a separate blockquote into the upload's
hint field so it sits in the dropzone where it matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:37:55 +02:00