Commit Graph

470 Commits

Author SHA1 Message Date
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
fs
e11db7d918 refactor(settings/sso): drop storage-metadata field hints, slim authority card
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>
2026-04-25 18:27:25 +02:00
fs
1f46ea602f refactor(settings/api): drop the duplicated CORS hint, slim token-policy intro
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>
2026-04-25 18:22:41 +02:00
fs
015d911ef8 fix(ui): give <small> a deterministic font-size so hints stay consistent
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>
2026-04-25 18:22:34 +02:00
fs
9ee2589820 refactor(settings/telemetry): single card with full sub-config disclosure
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>
2026-04-25 18:22:23 +02:00
fs
5f3aff08cb refactor(settings/audit): conditional retention disclosure + flat layout
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>
2026-04-25 11:50:04 +02:00
fs
466fcac866 refactor(settings/user-lifecycle): flat policy fields, danger stays in card
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>
2026-04-25 09:43:15 +02:00
fs
5b972b3633 refactor(settings): declutter account-access, group registration with user defaults
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>
2026-04-25 09:29:01 +02:00
fs
5d5cce7ad5 refactor(settings/general): drop redundant blockquotes, collapse 3 cards into 2
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>
2026-04-25 09:11:48 +02:00
fs
fdbf30379c feat(ui): tone-based icon palette for app-tile, dark-mode aware
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>
2026-04-25 09:11:35 +02:00
fs
c14d42f198 refactor(settings): split security into 4 focused tiles
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>
2026-04-25 08:50:05 +02:00
fs
dd5c305f7e fix: not opening in target blank 2026-04-24 22:43:37 +02:00
fs
69739c90d7 refactor(settings): split admin/settings into hub + per-section subpages
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>
2026-04-24 22:43:12 +02:00
fs
50851c0e9a manual commit 2026-04-24 21:53:14 +02:00
fs
874a90d8f8 refactor(admin-lists): slim tenant + user grids, Stripe-style pagination
Tenant list:
- Drop the avatar/logo column — the list now shows only Tenant name
  and user count. Also drop the logo-hasLogo server lookup and the
  initials/placeholder markup.

User list (13 → 4 columns, Stripe pattern):
- User (compound: avatar + Name + email subtitle, click opens drawer)
- Tenants (badges, primary tooltip)
- Last login (relative badge)
- State (active/inactive badge)
- UUID hidden at the last column for cells[uuidIndex].data
- Everything else (departments, roles, phone, mobile, short_dial,
  created, modified) lives in the detail drawer.
- Document the off-by-one gotcha: with row selection enabled gridjs
  prepends a checkbox cell, so runtime cells[] are shifted by +1;
  uuidIndex is 5 (column position 4 + selection offset).
- New .grid-user-profile css mirrors .grid-tenant-profile (avatar +
  stacked name/email) with ellipsis and primary-color hover affordance.

Pagination (all Grid.js lists):
- Non-current page buttons now use the neutral-chip secondary-outline
  tokens (--app-button-neutral-*) plus the raised box-shadow — matches
  the rest of the button system in both light and dark themes.
- Current page stays distinctly primary-filled with the filled-shadow;
  focus-visible combines the chip shadow with the primary focus ring.
- Disabled pager buttons remain neutral but lose the lift shadow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:52:44 +02:00
fs
fa86009e3f feat(ui): copyable input partial + dynamic copy-target
- New core partial templates/partials/app-input-copy.phtml renders a
  standard label + input with a compact copy button overlaid on the
  right edge of the input. Used for privacy/imprint URLs on the tenant
  form; reusable for any field where a one-click copy is helpful.
- Extend the shared copy-field component (web/js/components/app-copy-field.js)
  with data-copy-target support — the button now reads the target input's
  .value at click-time, so users can edit a field and still copy the
  current value. Static data-copy-value keeps working unchanged.
- New component CSS web/css/components/app-input-copy.css positions the
  button absolute/inset + margin-block:auto (robust vertical centering
  regardless of input height) and uses a descendant selector (0,2,0)
  so the button wins over the global [data-tooltip] position:relative.
- Also register app-input-copy.css + app-tenant-logo.css in core.css
  @import list — they were only in the shared asset group before, which
  default-template admin pages don't load, so tenant-logo styles were
  effectively missing on admin tenant edit (topbar size etc.).
- Document the Copy-to-Clipboard + Copyable Input standards in
  docs/reference-frontend-javascript.md so future consumers don't
  re-invent the markup/selectors.
- File-upload preview: pending-file block restructured to a compact
  list-item row (small square thumb + filename/size stack + clear X)
  and removed the transparency checker pattern on the current-image
  preview in favour of a flat --app-preview-bg surface.
- Tenant edit page title + breadcrumb now show the tenant description
  ("Acme GmbH") instead of the generic "Mandant bearbeiten" when one
  is present.
- i18n: add "Copy to clipboard" / "In Zwischenablage kopieren" across
  both locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:26:11 +02:00
fs
e814b5fd11 refactor(file-upload): SSR-first state, click-to-replace, cleaner preview
- Server renders initial file-upload state (src, alt, label text,
  has-current class) so there is no flash of broken-image icon while
  JS boots. JS no longer duplicates that work — it only reacts to
  user input (select, clear, drag-and-drop) and uses data-current-src
  solely to restore the server file after a blob preview.
- Clicking the current preview image now opens the file dialog, mirrors
  the Replace button, with hover affordance (cursor + primary border).
- Drop the transparency checker pattern in the preview — the admin UI
  does not need Figma-style transparency semantics. Preview is now a
  flat theme-aware surface (--app-preview-bg) that keeps white or
  black logos visible against a neutral gray.
- Move tenant favicon out of the aside into the Master-data tab as its
  own details block next to the Tenant-logos section. Uses the same
  barrier-form pattern (HTML5 form attribute + app-file-upload.phtml)
  for a consistent instant-upload UX.
- Tenant-logo slot label is a native <label> element — picks up the
  global form-label typography and spacing, no custom muted-color
  class needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:53:58 +02:00
fs
6e3fc63c1d feat(tenant): per-theme logos + file-upload + button UI polish
Replace the single tenant avatar with a pair of theme-scoped brand logos.
Render only the theme-matching <img> server-side and swap src on theme
toggle via a JS hook — no reload, no double request, no CSS tricks.

Tenant logos
- TenantLogoService (ImageUploadTrait) with theme whitelist and per-theme
  storage storage/tenants/{uuid}/logo/{light|dark}/, SIZES 128/256/512
- Public serving endpoint auth/tenant-logo-file so login can show the
  logo pre-auth; matching authenticated admin preview endpoint
- appTenantLogoUrl(?size, ?theme) with 4-step fallback cascade; PDF +
  mail always request 'light'
- Admin tenant edit: avatar block replaced by "Tenant logos" details
  block inside the Master-data tab, two side-by-side slots via Pico
  .grid with the core app-file-upload partial
- Policy rename ABILITY_ADMIN_TENANTS_AVATAR_VIEW -> LOGO_VIEW, action
  routes logo / logo-delete / logo-file with theme body/query param
- API endpoint path kept (backward compat), internals on new service
- CLI tenant:logo-migrate-avatars moves legacy avatar/ -> logo/light/
  idempotently (--dry-run, --yes, --cleanup)
- i18n "Tenant image" removed, 12 new keys synced across de/en

File upload component
- Full-width preview + filename/actions below (3D stack layout)
- Fixed 16:9 aspect ratio with 1rem inner padding for consistent
  preview size across any logo aspect
- Transparency checker pattern as background so black logos stay
  visible on dark mode and white logos on light mode
- form="" + deleteFormId support so the partial works with barrier
  forms inside another form

Buttons
- width:100% dropped from button[type="submit"]; scoped back via
  .login-main for the auth-flow primary CTA
- .outline base rule now tints background via color-mix of --app-color
  so secondary/primary/danger outlines all gain a subtle surface
- .outline.secondary restyled Stripe-style in both themes: solid white
  chip with soft shadow in light, solid elevated dark chip with white
  text in dark; neutral border replaces role-colored border
- .app-action-success/.app-action-danger outlines get color-mix bg +
  theme-aware outline-text tokens for stronger contrast
- Filled .primary/.app-action-success/.app-action-danger get raised
  box-shadow (inset highlight + drop) — opt-in via class so chrome
  buttons stay flat
- Dropped the legacy .secondary utility that was clobbering the
  custom-property cascade with a hardcoded muted color

Theme swap
- Logo img carries data-src-light + data-src-dark; theme-toggle JS
  swaps src when data-theme changes, keeping the topbar/login logo in
  sync without a page reload

Quality gates: PHPUnit (2045), PHPStan L5, CS-Fixer, docs link/drift,
codex skills sync — all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:25:53 +02:00
fs
dbeac6b095 refactor(support): complete toIntIds() rollout in tenant-area code
Final pass over tenant repositories and TenantScopeService, collapsing
the remaining two-line intval+filter and one-line intval+unique patterns
onto the shared `toIntIds()` helper. The inline pattern is now gone
from the codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:43:45 +02:00
fs
1c779b8eeb refactor(support): consolidate remaining ID normalization via toIntIds()
Extends the toIntIds() rollout to service and repository layers:

- Drops a third private duplicate (UserCustomFieldValueService::normalizeTenantIds)
- Collapses the two-line intval+filter pattern inside UserAssignmentService,
  UserAuthorizationPolicy, SsoUserLinkService, DepartmentService, and
  UserCustomFieldValueService
- Replaces inline patterns in 4 repositories (RolePermissionRepository,
  RoleAssignableRoleRepository, UserWriteRepository, DepartmentRepository,
  UserCustomFieldValueRepository, UserCustomFieldValueOptionRepository)
- Simplifies the notifications sanitizeTenantIds trait body

Tenant-area files deliberately untouched per parallel ongoing work on
the tenant-logo refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:18:45 +02:00
fs
6c99c040b2 refactor(support): centralize ID-array normalization in toIntIds()
Consolidates the scattered `array_values(array_unique(array_map('intval',
$x)))` + manual positive-filter pattern behind a single lenient helper
`toIntIds(mixed $value): array` in core/Support/helpers/array.php.

- `RepositoryArrayHelper::sanitizePositiveIds()` now delegates (keeps
  strict array-input contract + existing tests intact).
- Drops two private duplicates: `UserProfileViewService::normalizeIds()`
  and `AddressBookService::normalizeIds()`.
- Replaces 12 inline occurrences across admin action pages with the
  helper, cutting boilerplate by 3-5 lines per site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:11:53 +02:00
fs
52f10ae46c refactor(actions): centralize core page request/csrf guards 2026-04-24 19:10:37 +02:00
fs
a05a628968 refactor(css): remove animated login gradient
Strip the heavy animated multi-layer radial gradient from the login
page. Removes:
- ani-gradient keyframes and 40 @property declarations
- Light and dark theme gradient rules
- Empty #gradient div from login template
- prefers-reduced-motion override that only served the gradient

The login page now uses the plain app background color consistently.
2026-04-24 19:00:38 +02:00
fs
d7b73fcefe refactor(actions): complete admin request/csrf guard centralization 2026-04-24 19:00:13 +02:00
fs
5e705f780d refactor(css): overhaul dark mode palette to deep neutral tones
Align dark mode surfaces with a cooler, deeper aesthetic inspired by
Claude's dark theme:
- Background: #1f1f1e (was rgb(19,22.5,30.5))
- Sidebar: #171716 (separate from main background)
- Cards/Dropdowns: #242422 (was #181c25)
- Borders: #2f2f2d / #383835 (was blue-tinted grays)
- Text: neutralized grays (#c8c8c6, #6e6e6c etc.)
- Box-shadows: pure black rgba (was blue-tinted)

Also updates the self-contained error-debug page fallback tokens.

Both explicit [data-theme=dark] and prefers-color-scheme blocks are
kept in sync. High-contrast dark overrides remain untouched.
2026-04-24 18:54:39 +02:00
fs
da7e7c593e refactor(actions): centralize wave-2 post/csrf guards 2026-04-24 18:31:17 +02:00
fs
cfd867bcd7 refactor(actions): centralize POST/CSRF guard flow 2026-04-24 17:59:57 +02:00
fs
87652e55da refactor(system-info): reduce to Stripe-style status page
Reduce admin/system-info to a single focused status view: overall
status banner, components list from existing health checks, and an
environment meta table. Drops the Modules and Permissions tabs —
those were dev-diagnostics already available via CLI.

- Remove SystemInfoService + its test; wire the action directly to
  SystemHealthService and build meta inline
- Extend SystemHealthService with per-check label + description so
  the UI speaks in customer terms while the CLI doctor path stays
  compatible
- Rebuild the view on existing app-stats-table + app-empty-state
  primitives; add a small app-status-banner component for the
  headline signal
- Align help-center nav label and i18n keys (de/en)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:32:46 +02:00
fs
d9ed4ab5b4 refactor(ui): collapse Tile static class into appTile() helper
Align dashboard-tile primitive with the established helper-function +
partial convention (like tokenSelectForm / multiSelectForm). Keeps the
reusable substance (app-tile.phtml, .app-tile CSS) and makes the
primitive discoverable for modules via core/Support/helpers/ui.php.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:23:05 +02:00