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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The shared client ID and shared client secret carried "setting key for…"
DB descriptions that describe the storage row, not what the field means
to an admin — they leaked metadata into the form. The authority card
also had an info blockquote that paraphrased the URL placeholder and
the DB description below it.
Kept: the tenant-opt-in blockquote on the credentials card (it explains
that these values only apply where a tenant has enabled "Use shared app
credentials", which isn't obvious otherwise) and the "leave empty to
keep" hint on the secret input.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The token-policy card had an info blockquote that paraphrased the two
field labels, and the CORS card stated "one origin per line" three
times (blockquote, DB description, muted footer). Now the CORS hint
comes only from the setting description, which already includes the
per-line note in both locales. The danger warning on the revoke action
stays put.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The base small rule set --app-font-size: 0.875em but never consumed it,
so <small> elements fell back to the browser default ("smaller", roughly
0.83em of the parent). That made hints under fields visually drift
depending on the parent context — most noticeably on the telemetry page
where the muted hint, the per-field hint inside a label, and the caption
under a fieldset all rendered slightly different sizes.
The codebase already standardises on var(--text-xs) wherever a small was
explicitly styled (gridjs, footer, docs); this commit just makes the
default match that convention so every bare <small> is 12px regardless
of where it sits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapses the two telemetry cards (Frontend telemetry + Advanced) into
one card so the master toggle and its sub-config (sampling + allowed
events) read as one coherent block. The conditional disclosure now
hides the entire sub-config when telemetry is off — previously only the
sampling fieldset hid, leaving Advanced visible with no effect.
The sampling select sits in a 2-column grid with an empty filler so it
keeps a sensible width instead of stretching across the page. Both
redundant info blockquotes are gone (the master switch carries a muted
hint, and the events block has its own caption). The simplified
component drops the now-redundant samplingRowSelector and consumes a
single data-telemetry-when-enabled wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the wrapping details-card so the audit toggle and retention input
read like the email page. The audit switch now uses role="switch" and a
new app-settings-audit component (built on the existing
createConditionalToggleInit primitive) hides the retention block when
audit is disabled — the input is meaningless without audit on, and the
control state syncs automatically on toggle. The redundant info
blockquote and the toggle's paraphrased DB description are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the wrapping details-card around the inactivity-policy fields so
they read like the email page (flat grid, no extra chrome). The
"Run lifecycle now" danger action keeps its details-card so the
destructive button stays visually separated from the form inputs.
The redundant info blockquote that paraphrased the field labels is gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related cleanups in the same area:
- Account-access loses 4 info blockquotes that paraphrased their cards,
4 redundant "allowed range" hints (the input min/max + DB descriptions
already convey the bounds), and the fieldset wrappers around single
checkboxes.
- The registration toggle moves out of account-access into general's
user-creation card (renamed to "User onboarding"), so all "new user"
settings live together while account-access stays focused on existing
user sessions.
- Both feature toggles (allow registration, Microsoft auto-remember)
switch to role="switch" with the description sitting outside the label
as <small class="muted">, matching the tenant form pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App-title and language sit in one App identity card now (both single-field
sections; separate cards were chrome-heavy). The trivial info blockquotes
that paraphrased the field labels are gone, and the per-field descriptions
on the user creation defaults dropped because the section blockquote
already says when the values apply. The "User creation rules" label is
renamed to "User creation defaults" so the card title matches its scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an iconTone API (blue, violet, red, orange, amber, emerald, cyan,
pink, green, neutral) to appTile() that derives both icon background and
color from a single hue via color-mix(). Light mode keeps the pastel-pill
look; dark mode picks subtle dark-tinted backgrounds with bright accent
icons so tiles read clearly on the dark background.
The existing iconBg/iconColor escape hatch stays for callers that need a
custom hex (admin/stats); the nine settings tiles migrate to iconTone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts user-lifecycle, audit and telemetry from the security subpage
into their own tiles, and renames the slimmed-down security subpage to
account-access for a clearer scope. Each subpage now has at most three
detail cards instead of the eight previously crowded into security.
Hub gains four tiles, sub-action redirects (expire-remember-tokens,
run-user-lifecycle) move to their new sections, architecture tests track
the new section list and i18n adds the new labels in de + en.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits the 755-line monolithic settings page into a tile-based landing hub
and six focused subpages (general, security, email, api, sso, branding),
each with its own form, CSRF scope and POST handler. Each subpage offers
Save / Save & close buttons plus a Cancel/back link to the hub.
Backend (AdminSettingsService, gateways, policies, DB schema) unchanged.
A new settingsSectionMergePost() helper overlays section POSTs onto the
current DB values so partial saves don't wipe unrelated fields (the
service defaults missing keys to 0/empty).
Sub-action files (logo/favicon/tokens/lifecycle) redirect to the matching
subpage, and architecture contracts now check the subpage files instead
of the removed monolithic index.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>