1
0

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>
This commit is contained in:
2026-04-26 20:36:28 +02:00
parent 004c25ac4b
commit cc2cf3a254
19 changed files with 656 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Repository\Access\UserRoleRepository; use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Repository\Org\UserDepartmentRepository; use MintyPHP\Repository\Org\UserDepartmentRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepository;
use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Repository\User\UserWriteRepository;
use MintyPHP\Service\Auth\TenantSsoService; use MintyPHP\Service\Auth\TenantSsoService;
@@ -19,6 +20,7 @@ use MintyPHP\Service\User\UserApiWriteInputMapper;
use MintyPHP\Service\User\UserAssignmentService; use MintyPHP\Service\User\UserAssignmentService;
use MintyPHP\Service\User\UserAvatarService; use MintyPHP\Service\User\UserAvatarService;
use MintyPHP\Service\User\UserDirectoryGateway; use MintyPHP\Service\User\UserDirectoryGateway;
use MintyPHP\Service\User\UserLifecyclePolicyDashboardService;
use MintyPHP\Service\User\UserLifecycleRestoreService; use MintyPHP\Service\User\UserLifecycleRestoreService;
use MintyPHP\Service\User\UserLifecycleService; use MintyPHP\Service\User\UserLifecycleService;
use MintyPHP\Service\User\UserPasswordPolicyService; use MintyPHP\Service\User\UserPasswordPolicyService;
@@ -61,6 +63,10 @@ final class UserRegistrar implements ContainerRegistrar
$container->set(UserTenantRepository::class, static fn (AppContainer $c): UserTenantRepository => $c->get(UserRepositoryFactory::class)->createUserTenantRepository()); $container->set(UserTenantRepository::class, static fn (AppContainer $c): UserTenantRepository => $c->get(UserRepositoryFactory::class)->createUserTenantRepository());
$container->set(UserRoleRepository::class, static fn (AppContainer $c): UserRoleRepository => $c->get(UserRepositoryFactory::class)->createUserRoleRepository()); $container->set(UserRoleRepository::class, static fn (AppContainer $c): UserRoleRepository => $c->get(UserRepositoryFactory::class)->createUserRoleRepository());
$container->set(UserDepartmentRepository::class, static fn (AppContainer $c): UserDepartmentRepository => $c->get(UserRepositoryFactory::class)->createUserDepartmentRepository()); $container->set(UserDepartmentRepository::class, static fn (AppContainer $c): UserDepartmentRepository => $c->get(UserRepositoryFactory::class)->createUserDepartmentRepository());
$container->set(UserLifecyclePolicyDashboardRepository::class, static fn (AppContainer $c): UserLifecyclePolicyDashboardRepository => $c->get(UserRepositoryFactory::class)->createUserLifecyclePolicyDashboardRepository());
$container->set(UserLifecyclePolicyDashboardService::class, static fn (AppContainer $c): UserLifecyclePolicyDashboardService => new UserLifecyclePolicyDashboardService(
$c->get(UserLifecyclePolicyDashboardRepository::class)
));
$container->set(UserProfileViewService::class, static fn (AppContainer $c): UserProfileViewService => new UserProfileViewService( $container->set(UserProfileViewService::class, static fn (AppContainer $c): UserProfileViewService => new UserProfileViewService(
$c->get(UserAccountService::class), $c->get(UserAccountService::class),
$c->get(UserAssignmentService::class), $c->get(UserAssignmentService::class),

View File

@@ -23,6 +23,8 @@ use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
use MintyPHP\Service\Audit\NullAuditRecorder; use MintyPHP\Service\Audit\NullAuditRecorder;
use MintyPHP\Service\Audit\NullImportAudit; use MintyPHP\Service\Audit\NullImportAudit;
use MintyPHP\Service\Audit\NullUserLifecycleAudit; use MintyPHP\Service\Audit\NullUserLifecycleAudit;
use MintyPHP\Service\Audit\NullUserLifecycleAuditDashboard;
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface; use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
$container = new AppContainer(); $container = new AppContainer();
@@ -85,6 +87,9 @@ if (!$container->has(AuditRecorderInterface::class)) {
if (!$container->has(UserLifecycleAuditInterface::class)) { if (!$container->has(UserLifecycleAuditInterface::class)) {
$container->set(UserLifecycleAuditInterface::class, static fn (): UserLifecycleAuditInterface => new NullUserLifecycleAudit()); $container->set(UserLifecycleAuditInterface::class, static fn (): UserLifecycleAuditInterface => new NullUserLifecycleAudit());
} }
if (!$container->has(UserLifecycleAuditDashboardInterface::class)) {
$container->set(UserLifecycleAuditDashboardInterface::class, static fn (): UserLifecycleAuditDashboardInterface => new NullUserLifecycleAuditDashboard());
}
if (!$container->has(ImportAuditInterface::class)) { if (!$container->has(ImportAuditInterface::class)) {
$container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit()); $container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit());
} }

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Repository\User;
use MintyPHP\DB;
/**
* Counts users in the deletion window for the policy-dashboard tile. Time base is UTC, mirroring
* {@see UserReadRepository::listIdsForAutoDelete} for behavioural equivalence.
*/
class UserLifecyclePolicyDashboardRepository implements UserLifecyclePolicyDashboardRepositoryInterface
{
public function countPendingDeletion(int $deleteDays, int $windowDays): int
{
if ($deleteDays <= 0 || $windowDays <= 0) {
return 0;
}
// Lower bound (deleteDays - windowDays) is clamped to 0 so a misconfigured tiny deleteDays
// value cannot produce a negative INTERVAL; the upper bound stays at deleteDays.
$lowerBoundDays = max(0, $deleteDays - $windowDays);
$value = DB::selectValue(
'select count(*)
from users
where active = 0
and active_changed_at is not null
and active_changed_at <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)
and active_changed_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)',
(string) $lowerBoundDays,
(string) $deleteDays
);
return (int) ($value ?? 0);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace MintyPHP\Repository\User;
/** Read-only contract for user lifecycle policy dashboard queries (KPI tiles). */
interface UserLifecyclePolicyDashboardRepositoryInterface
{
/**
* Count users currently deactivated whose deletion-due date falls inside the next $windowDays days.
*
* Counts users in the 7-day deletion window using active_changed_at as anchor; users without
* active_changed_at are excluded (cannot be scheduled for deletion).
*/
public function countPendingDeletion(int $deleteDays, int $windowDays): int;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* No-op user lifecycle dashboard reader used when the audit module is disabled.
*/
final class NullUserLifecycleAuditDashboard implements UserLifecycleAuditDashboardInterface
{
public function lastRun(): ?array
{
return null;
}
public function actionCountInWindow(string $action, int $days, string $status = 'success'): int
{
return 0;
}
public function summaryByAction(int $days, string $status = 'success'): array
{
return [];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace MintyPHP\Service\Audit;
/**
* Read-only counterpart to UserLifecycleAuditInterface for dashboard / KPI queries.
*
* Implemented by the audit module's UserLifecycleAuditDashboardService when active.
* Falls back to NullUserLifecycleAuditDashboard when the audit module is disabled.
*/
interface UserLifecycleAuditDashboardInterface
{
/**
* Latest automatic (system-triggered) lifecycle run, or null when none recorded.
*
* @return array{created_at:string,status:string,action:string}|null
*/
public function lastRun(): ?array;
/**
* Count successful (or status-filtered) audit-log events for a single action within the last $days.
*/
public function actionCountInWindow(string $action, int $days, string $status = 'success'): int;
/**
* Aggregated count per action within the last $days, filtered by status.
*
* @return array<string, int>
*/
public function summaryByAction(int $days, string $status = 'success'): array;
}

View File

@@ -0,0 +1,29 @@
<?php
namespace MintyPHP\Service\User;
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepositoryInterface;
/**
* Computes the "Pending deletion" KPI for the user lifecycle settings dashboard.
*
* If either policy threshold (deactivate / delete days) is disabled, the service short-circuits
* to 0 without issuing a database query.
*
* @api
*/
final class UserLifecyclePolicyDashboardService
{
public function __construct(
private readonly UserLifecyclePolicyDashboardRepositoryInterface $userLifecyclePolicyDashboardRepository
) {
}
public function pendingDeletionCount(int $deactivateDays, int $deleteDays, int $windowDays = 7): int
{
if ($deactivateDays <= 0 || $deleteDays <= 0 || $windowDays <= 0) {
return 0;
}
return $this->userLifecyclePolicyDashboardRepository->countPendingDeletion($deleteDays, $windowDays);
}
}

View File

@@ -8,6 +8,8 @@ use MintyPHP\Repository\Org\UserDepartmentRepository;
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface; use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepository;
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepositoryInterface;
use MintyPHP\Repository\User\UserListQueryRepository; use MintyPHP\Repository\User\UserListQueryRepository;
use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserReadRepository;
@@ -23,6 +25,7 @@ class UserRepositoryFactory
private ?UserTenantRepository $userTenantRepository = null; private ?UserTenantRepository $userTenantRepository = null;
private ?UserRoleRepository $userRoleRepository = null; private ?UserRoleRepository $userRoleRepository = null;
private ?UserDepartmentRepository $userDepartmentRepository = null; private ?UserDepartmentRepository $userDepartmentRepository = null;
private ?UserLifecyclePolicyDashboardRepository $userLifecyclePolicyDashboardRepository = null;
public function createUserReadRepository(): UserReadRepositoryInterface public function createUserReadRepository(): UserReadRepositoryInterface
{ {
@@ -53,4 +56,12 @@ class UserRepositoryFactory
{ {
return $this->userDepartmentRepository ??= new UserDepartmentRepository(); return $this->userDepartmentRepository ??= new UserDepartmentRepository();
} }
/**
* @api
*/
public function createUserLifecyclePolicyDashboardRepository(): UserLifecyclePolicyDashboardRepositoryInterface
{
return $this->userLifecyclePolicyDashboardRepository ??= new UserLifecyclePolicyDashboardRepository();
}
} }

View File

@@ -1381,5 +1381,19 @@
"Your LDAP account does not have an email address configured.": "Ihr LDAP-Konto hat keine E-Mail-Adresse konfiguriert.", "Your LDAP account does not have an email address configured.": "Ihr LDAP-Konto hat keine E-Mail-Adresse konfiguriert.",
"Your session has expired. Please log in again.": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an. Mit „Angemeldet bleiben“ erfolgt die Anmeldung ggf. automatisch.", "Your session has expired. Please log in again.": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an. Mit „Angemeldet bleiben“ erfolgt die Anmeldung ggf. automatisch.",
"Your session will expire in {countdown}. Would you like to continue?": "Ihre Sitzung läuft in {countdown} ab. Möchten Sie fortfahren?", "Your session will expire in {countdown}. Would you like to continue?": "Ihre Sitzung läuft in {countdown} ab. Möchten Sie fortfahren?",
"ZIP support missing (ext-zip)": "ZIP-Unterstützung fehlt (ext-zip)" "ZIP support missing (ext-zip)": "ZIP-Unterstützung fehlt (ext-zip)",
"Deactivated": "Deaktiviert",
"Deleted": "Gelöscht",
"Pending deletion": "Fällige Löschung",
"No runs yet": "Bisher keine Läufe",
"Policy reference": "Policy-Referenz",
"View activity": "Aktivität anzeigen",
"Last 30 days": "Letzte 30 Tage",
"Next 7 days": "Nächste 7 Tage",
"%d days ago": "vor %d Tagen",
"today": "heute",
"yesterday": "gestern",
"Run policy now": "Policy jetzt ausführen",
"Purge logs": "Logs bereinigen",
"Purge old user lifecycle audit log entries?": "Alte Benutzer-Lifecycle-Audit-Einträge bereinigen?"
} }

View File

@@ -1381,5 +1381,19 @@
"Your LDAP account does not have an email address configured.": "Your LDAP account does not have an email address configured.", "Your LDAP account does not have an email address configured.": "Your LDAP account does not have an email address configured.",
"Your session has expired. Please log in again.": "Your session timed out. Please sign in again. If you chose Remember me, sign-in can happen automatically.", "Your session has expired. Please log in again.": "Your session timed out. Please sign in again. If you chose Remember me, sign-in can happen automatically.",
"Your session will expire in {countdown}. Would you like to continue?": "Your session will expire in {countdown}. Would you like to continue?", "Your session will expire in {countdown}. Would you like to continue?": "Your session will expire in {countdown}. Would you like to continue?",
"ZIP support missing (ext-zip)": "ZIP support missing (ext-zip)" "ZIP support missing (ext-zip)": "ZIP support missing (ext-zip)",
"Deactivated": "Deactivated",
"Deleted": "Deleted",
"Pending deletion": "Pending deletion",
"No runs yet": "No runs yet",
"Policy reference": "Policy reference",
"View activity": "View activity",
"Last 30 days": "Last 30 days",
"Next 7 days": "Next 7 days",
"%d days ago": "%d days ago",
"today": "today",
"yesterday": "yesterday",
"Run policy now": "Run policy now",
"Purge logs": "Purge logs",
"Purge old user lifecycle audit log entries?": "Purge old user lifecycle audit log entries?"
} }

View File

@@ -25,12 +25,14 @@ use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService; use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter; use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter;
use MintyPHP\Module\Audit\Service\SystemAuditService; use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditDashboardService;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService; use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface; use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
use MintyPHP\Service\Audit\AuditRecorderInterface; use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Audit\ImportAuditInterface; use MintyPHP\Service\Audit\ImportAuditInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface; use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
use MintyPHP\Service\Security\RateLimiterService; use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway; use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
@@ -64,6 +66,9 @@ final class AuditContainerRegistrar implements ContainerRegistrar
$container->set(UserLifecycleAuditService::class, static fn (AppContainer $c): UserLifecycleAuditService => new UserLifecycleAuditService( $container->set(UserLifecycleAuditService::class, static fn (AppContainer $c): UserLifecycleAuditService => new UserLifecycleAuditService(
$c->get(UserLifecycleAuditRepository::class) $c->get(UserLifecycleAuditRepository::class)
)); ));
$container->set(UserLifecycleAuditDashboardService::class, static fn (AppContainer $c): UserLifecycleAuditDashboardService => new UserLifecycleAuditDashboardService(
$c->get(UserLifecycleAuditRepository::class)
));
$container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => new ImportAuditService( $container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => new ImportAuditService(
$c->get(ImportAuditRunRepository::class) $c->get(ImportAuditRunRepository::class)
)); ));
@@ -71,6 +76,7 @@ final class AuditContainerRegistrar implements ContainerRegistrar
// Core interface bindings — override null implementations // Core interface bindings — override null implementations
$container->set(AuditRecorderInterface::class, static fn (AppContainer $c): AuditRecorderInterface => $c->get(SystemAuditService::class)); $container->set(AuditRecorderInterface::class, static fn (AppContainer $c): AuditRecorderInterface => $c->get(SystemAuditService::class));
$container->set(UserLifecycleAuditInterface::class, static fn (AppContainer $c): UserLifecycleAuditInterface => $c->get(UserLifecycleAuditService::class)); $container->set(UserLifecycleAuditInterface::class, static fn (AppContainer $c): UserLifecycleAuditInterface => $c->get(UserLifecycleAuditService::class));
$container->set(UserLifecycleAuditDashboardInterface::class, static fn (AppContainer $c): UserLifecycleAuditDashboardInterface => $c->get(UserLifecycleAuditDashboardService::class));
$container->set(ImportAuditInterface::class, static fn (AppContainer $c): ImportAuditInterface => $c->get(ImportAuditService::class)); $container->set(ImportAuditInterface::class, static fn (AppContainer $c): ImportAuditInterface => $c->get(ImportAuditService::class));
$container->set(ApiAuditServiceInterface::class, static fn (AppContainer $c): ApiAuditServiceInterface => $c->get(ApiAuditService::class)); $container->set(ApiAuditServiceInterface::class, static fn (AppContainer $c): ApiAuditServiceInterface => $c->get(ApiAuditService::class));
$container->set(ApiSystemAuditReporterInterface::class, static fn (AppContainer $c): ApiSystemAuditReporterInterface => $c->get(ApiSystemAuditReporter::class)); $container->set(ApiSystemAuditReporterInterface::class, static fn (AppContainer $c): ApiSystemAuditReporterInterface => $c->get(ApiSystemAuditReporter::class));

View File

@@ -278,6 +278,92 @@ class UserLifecycleAuditRepository
return (int) $updated > 0; return (int) $updated > 0;
} }
/**
* Most recent system-triggered lifecycle run (any status), used by the policy dashboard tile.
*
* @return array{created_at:string,status:string,action:string}|null
*/
public function latestSystemRun(): ?array
{
$row = DB::selectOne(
'select created_at, status, action
from user_lifecycle_audit_log
where trigger_type = ?
order by created_at desc
limit 1',
UserLifecycleTriggerType::System->value
);
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? $row;
if (!is_array($item) || !isset($item['created_at'])) {
return null;
}
return [
'created_at' => (string) $item['created_at'],
'status' => (string) ($item['status'] ?? ''),
'action' => (string) ($item['action'] ?? ''),
];
}
/**
* Count audit-log events for a single action+status within the last $days days (UTC).
*/
public function countActionInWindow(string $action, int $days, string $status): int
{
if ($days <= 0) {
return 0;
}
$value = DB::selectValue(
'select count(*)
from user_lifecycle_audit_log
where action = ?
and status = ?
and created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)',
$action,
$status,
(string) $days
);
return (int) ($value ?? 0);
}
/**
* Map of action → count for the given status within the last $days days (UTC).
*
* @return array<string, int>
*/
public function sumByActionInWindow(int $days, string $status): array
{
if ($days <= 0) {
return [];
}
$rows = DB::select(
'select action, count(*) as cnt
from user_lifecycle_audit_log
where status = ?
and created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)
group by action',
$status,
(string) $days
);
$summary = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = is_array($row) ? ($row['user_lifecycle_audit_log'] ?? $row) : null;
if (!is_array($item)) {
continue;
}
$action = trim((string) ($item['action'] ?? ''));
if ($action === '') {
continue;
}
$summary[$action] = (int) ($item['cnt'] ?? 0);
}
}
return $summary;
}
public function purgeOlderThanDays(int $days): int public function purgeOlderThanDays(int $days): int
{ {
if ($days <= 0) { if ($days <= 0) {

View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Module\Audit\Domain\UserLifecycleAction;
use MintyPHP\Module\Audit\Domain\UserLifecycleStatus;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepository;
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
/**
* Read-side dashboard implementation that delegates to {@see UserLifecycleAuditRepository}
* and normalizes every input via the lifecycle taxonomy enums (defence-in-depth).
*/
final class UserLifecycleAuditDashboardService implements UserLifecycleAuditDashboardInterface
{
public function __construct(private readonly UserLifecycleAuditRepository $userLifecycleAuditRepository)
{
}
public function lastRun(): ?array
{
return $this->userLifecycleAuditRepository->latestSystemRun();
}
public function actionCountInWindow(string $action, int $days, string $status = 'success'): int
{
$normalizedAction = UserLifecycleAction::tryNormalize($action);
if ($normalizedAction === null) {
return 0;
}
$normalizedStatus = UserLifecycleStatus::tryNormalize($status);
if ($normalizedStatus === null) {
return 0;
}
return $this->userLifecycleAuditRepository->countActionInWindow(
$normalizedAction->value,
$days,
$normalizedStatus->value
);
}
public function summaryByAction(int $days, string $status = 'success'): array
{
$normalizedStatus = UserLifecycleStatus::tryNormalize($status);
if ($normalizedStatus === null) {
return [];
}
return $this->userLifecycleAuditRepository->sumByActionInWindow($days, $normalizedStatus->value);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepository;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditDashboardService;
use PHPUnit\Framework\TestCase;
class UserLifecycleAuditDashboardServiceTest extends TestCase
{
public function testLastRunReturnsNullWhenRepositoryHasNoSystemRun(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->expects($this->once())->method('latestSystemRun')->willReturn(null);
$service = new UserLifecycleAuditDashboardService($repository);
$this->assertNull($service->lastRun());
}
public function testLastRunNormalizesRowToContractShape(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->method('latestSystemRun')->willReturn([
'created_at' => '2026-04-25 12:00:00',
'status' => 'success',
'action' => 'deactivate',
]);
$service = new UserLifecycleAuditDashboardService($repository);
$row = $service->lastRun();
$this->assertIsArray($row);
$this->assertSame('2026-04-25 12:00:00', $row['created_at']);
$this->assertSame('success', $row['status']);
$this->assertSame('deactivate', $row['action']);
}
public function testActionCountInWindowReturnsZeroForUnknownAction(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->expects($this->never())->method('countActionInWindow');
$service = new UserLifecycleAuditDashboardService($repository);
$this->assertSame(0, $service->actionCountInWindow('not-a-real-action', 30));
}
public function testActionCountInWindowReturnsZeroForUnknownStatus(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->expects($this->never())->method('countActionInWindow');
$service = new UserLifecycleAuditDashboardService($repository);
$this->assertSame(0, $service->actionCountInWindow('deactivate', 30, 'not-a-status'));
}
public function testActionCountInWindowDelegatesNormalizedValuesToRepository(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->expects($this->once())
->method('countActionInWindow')
->with('deactivate', 30, 'success')
->willReturn(7);
$service = new UserLifecycleAuditDashboardService($repository);
$this->assertSame(7, $service->actionCountInWindow('Deactivate', 30, 'Success'));
}
public function testSummaryByActionReturnsEmptyForUnknownStatus(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->expects($this->never())->method('sumByActionInWindow');
$service = new UserLifecycleAuditDashboardService($repository);
$this->assertSame([], $service->summaryByAction(30, 'not-a-status'));
}
public function testSummaryByActionDelegatesNormalizedStatus(): void
{
$repository = $this->createMock(UserLifecycleAuditRepository::class);
$repository->expects($this->once())
->method('sumByActionInWindow')
->with(30, 'success')
->willReturn(['deactivate' => 4, 'delete' => 2]);
$service = new UserLifecycleAuditDashboardService($repository);
$this->assertSame(
['deactivate' => 4, 'delete' => 2],
$service->summaryByAction(30, 'Success')
);
}
}

View File

@@ -4,7 +4,10 @@ use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Access\SettingsAuthorizationPolicy; use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
use MintyPHP\Service\Audit\NullUserLifecycleAuditDashboard;
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
use MintyPHP\Service\Settings\AdminSettingsService; use MintyPHP\Service\Settings\AdminSettingsService;
use MintyPHP\Service\User\UserLifecyclePolicyDashboardService;
use MintyPHP\Support\Flash; use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -75,6 +78,135 @@ if ($request->isMethod('POST')) {
Router::redirect($redirectTarget); Router::redirect($redirectTarget);
} }
// ── KPI cockpit data (server-rendered, no async) ─────────────────────
$deactivateDays = (int) ($values['user_inactivity_deactivate_days'] ?? 0);
$deleteDays = (int) ($values['user_inactivity_delete_days'] ?? 0);
$dashboardService = app(UserLifecyclePolicyDashboardService::class);
$auditDashboard = app(UserLifecycleAuditDashboardInterface::class);
$auditDashboardActive = !($auditDashboard instanceof NullUserLifecycleAuditDashboard);
$lastRun = $auditDashboard->lastRun();
$summary = $auditDashboard->summaryByAction(30);
$pendingCount = $dashboardService->pendingDeletionCount($deactivateDays, $deleteDays, 7);
$lastRunAt = $lastRun !== null ? (string) ($lastRun['created_at'] ?? '') : null;
$lastRunStatus = $lastRun !== null ? (string) ($lastRun['status'] ?? '') : null;
if ($lastRunAt !== null && $lastRunAt === '') {
$lastRunAt = null;
}
// Build relative-time + status suffix for the Run-Now <details> summary line and the KPI tile.
$lastRunRelative = '—';
$lastRunStatusLabel = '';
if ($lastRunAt !== null) {
try {
$createdUtc = (new \DateTimeImmutable($lastRunAt, new \DateTimeZone('UTC')))->getTimestamp();
$diffDays = (int) floor((time() - $createdUtc) / 86400);
if ($diffDays <= 0) {
$lastRunRelative = t('today');
} elseif ($diffDays === 1) {
$lastRunRelative = t('yesterday');
} else {
$lastRunRelative = sprintf(t('%d days ago'), $diffDays);
}
} catch (\Throwable) {
$lastRunRelative = '—';
}
if ($lastRunStatus !== null && $lastRunStatus !== '') {
// Status enum tokens are translated via existing 'Success'/'Failed'/'Skipped' keys.
$statusKey = ucfirst(strtolower($lastRunStatus));
$lastRunStatusLabel = t($statusKey);
}
}
$lastRunSummary = '';
if ($lastRunAt !== null) {
$lastRunSummary = sprintf(
'%s: %s%s',
t('Last run'),
$lastRunRelative,
$lastRunStatusLabel !== '' ? ' · ' . $lastRunStatusLabel : ''
);
} else {
$lastRunSummary = t('No runs yet');
}
$lastRunTooltip = $lastRunStatusLabel !== '' ? $lastRunStatusLabel : t('No runs yet');
$pendingDisabled = ($deactivateDays <= 0 || $deleteDays <= 0);
/** @var array<int, array<string, mixed>> $kpiTiles */
$kpiTiles = [
[
'label' => t('Last run'),
'count' => $lastRunAt !== null ? $lastRunRelative : '—',
'icon' => 'bi bi-clock-history',
'iconTone' => 'blue',
'href' => 'admin/settings/user-lifecycle',
'tooltip' => $lastRunTooltip,
],
[
'label' => t('Deactivated'),
'count' => $auditDashboardActive ? (string) ((int) ($summary['deactivate'] ?? 0)) : '—',
'icon' => 'bi bi-person-dash',
'iconTone' => 'amber',
'href' => 'admin/settings/user-lifecycle',
'tooltip' => t('Last 30 days'),
],
[
'label' => t('Deleted'),
'count' => $auditDashboardActive ? (string) ((int) ($summary['delete'] ?? 0)) : '—',
'icon' => 'bi bi-person-x',
'iconTone' => 'red',
'href' => 'admin/settings/user-lifecycle',
'tooltip' => t('Last 30 days'),
],
[
'label' => t('Pending deletion'),
'count' => $pendingDisabled ? '—' : (string) $pendingCount,
'icon' => 'bi bi-exclamation-triangle',
'iconTone' => 'orange',
'href' => 'admin/users',
'tooltip' => t('Next 7 days'),
],
];
// ── Aside quick actions ──────────────────────────────────────────────
/** @var array<int, array<string, mixed>> $asideActions */
$asideActions = [];
if ($canUpdateSettings) {
$asideActions[] = [
'type' => 'form',
'label' => t('Run policy now'),
'action' => 'admin/settings/run-user-lifecycle',
'method' => 'POST',
'class' => 'secondary outline small',
'tone' => 'danger',
'confirm' => t('Run user lifecycle now?'),
'detailActionKind' => 'danger',
];
}
if ($canUpdateSettings && $auditDashboardActive) {
$asideActions[] = [
'type' => 'form',
'label' => t('Purge logs'),
'action' => 'admin/settings/user-lifecycle/audit-purge',
'method' => 'POST',
'class' => 'secondary outline small',
'tone' => 'danger',
'confirm' => t('Purge old user lifecycle audit log entries?'),
'detailActionKind' => 'purge',
];
}
$asideActions[] = [
'type' => 'link',
'label' => t('Policy reference'),
'href' => 'docs/reference-benutzer-lifecycle-policy.md',
'class' => 'secondary outline small',
'target' => '_blank',
'rel' => 'noopener',
];
Buffer::set('title', t('User lifecycle settings')); Buffer::set('title', t('User lifecycle settings'));
$breadcrumbs = [ $breadcrumbs = [

View File

@@ -4,6 +4,9 @@
* @var array $values * @var array $values
* @var array $settings * @var array $settings
* @var bool $canUpdateSettings * @var bool $canUpdateSettings
* @var array<int, array<string, mixed>> $kpiTiles
* @var string $lastRunSummary
* @var array<int, array<string, mixed>> $asideActions
*/ */
use MintyPHP\Session; use MintyPHP\Session;
@@ -17,6 +20,9 @@ $userInactivityDeleteDaysDesc = $settings['user_inactivity_delete_days']['descri
$canUpdateSettings = (bool) ($canUpdateSettings ?? false); $canUpdateSettings = (bool) ($canUpdateSettings ?? false);
$isReadOnly = !$canUpdateSettings; $isReadOnly = !$canUpdateSettings;
$readonlyAttr = $isReadOnly ? 'readonly' : ''; $readonlyAttr = $isReadOnly ? 'readonly' : '';
$kpiTiles = is_array($kpiTiles ?? null) ? $kpiTiles : [];
$lastRunSummary = (string) ($lastRunSummary ?? '');
$asideActions = is_array($asideActions ?? null) ? $asideActions : [];
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : []; $layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; $layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : []; $moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
@@ -52,6 +58,8 @@ $moduleLifecyclePanelSlots = is_array($moduleSlots['settings.user_lifecycle.pane
require templatePath('partials/app-details-titlebar.phtml'); require templatePath('partials/app-details-titlebar.phtml');
?> ?>
<?php require templatePath('partials/app-kpi-row.phtml'); ?>
<form id="settings-user-lifecycle-form" method="post" data-details-storage="settings-user-lifecycle-details-v1" data-standard-detail-form="1"> <form id="settings-user-lifecycle-form" method="post" data-details-storage="settings-user-lifecycle-details-v1" data-standard-detail-form="1">
<div class="grid"> <div class="grid">
<label class="app-field"> <label class="app-field">
@@ -74,6 +82,9 @@ $moduleLifecyclePanelSlots = is_array($moduleSlots['settings.user_lifecycle.pane
<details class="app-details-card" name="settings-user-lifecycle-run-now" data-details-key="settings-user-lifecycle-run-now"> <details class="app-details-card" name="settings-user-lifecycle-run-now" data-details-key="settings-user-lifecycle-run-now">
<summary> <summary>
<span class="app-details-card-summary-title"><?php e(t('Run lifecycle now')); ?></span> <span class="app-details-card-summary-title"><?php e(t('Run lifecycle now')); ?></span>
<?php if ($lastRunSummary !== ''): ?>
<span class="app-details-card-summary-meta"><?php e($lastRunSummary); ?></span>
<?php endif; ?>
</summary> </summary>
<div class="app-details-card-container"> <div class="app-details-card-container">
<blockquote data-variant="warning"> <blockquote data-variant="warning">
@@ -111,6 +122,8 @@ $moduleLifecyclePanelSlots = is_array($moduleSlots['settings.user_lifecycle.pane
<?php endforeach; ?> <?php endforeach; ?>
</section> </section>
<aside id="app-details-aside-section"> <aside id="app-details-aside-section">
<div class="app-details-aside-section"></div> <div class="app-details-aside-section">
<?php require templatePath('partials/app-details-aside-actions.phtml'); ?>
</div>
</aside> </aside>
</div> </div>

View File

@@ -0,0 +1,20 @@
<?php
/**
* Generic KPI tile row. Renders a responsive grid of {@see appTile()} entries.
* Empty array short-circuits without emitting any markup.
*
* @var array<int, array<string, mixed>> $kpiTiles
*/
$kpiTiles = is_array($kpiTiles ?? null) ? $kpiTiles : [];
if ($kpiTiles === []) {
return;
}
?>
<div class="app-tiles">
<?php foreach ($kpiTiles as $tile): ?>
<?php if (is_array($tile)) { appTile($tile); } ?>
<?php endforeach; ?>
</div>
<hr>

View File

@@ -28,6 +28,8 @@ final class AuditModuleIsolationContractTest extends TestCase
'NullAuditRecorder.php', 'NullAuditRecorder.php',
'NullImportAudit.php', 'NullImportAudit.php',
'NullUserLifecycleAudit.php', 'NullUserLifecycleAudit.php',
'NullUserLifecycleAuditDashboard.php',
'UserLifecycleAuditDashboardInterface.php',
'UserLifecycleAuditInterface.php', 'UserLifecycleAuditInterface.php',
]; ];

View File

@@ -0,0 +1,64 @@
<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepositoryInterface;
use MintyPHP\Service\User\UserLifecyclePolicyDashboardService;
use PHPUnit\Framework\TestCase;
class UserLifecyclePolicyDashboardServiceTest extends TestCase
{
public function testReturnsZeroWhenDeactivateDaysIsZero(): void
{
$repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class);
$repository->expects($this->never())->method('countPendingDeletion');
$service = new UserLifecyclePolicyDashboardService($repository);
$this->assertSame(0, $service->pendingDeletionCount(0, 365, 7));
}
public function testReturnsZeroWhenDeleteDaysIsZero(): void
{
$repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class);
$repository->expects($this->never())->method('countPendingDeletion');
$service = new UserLifecyclePolicyDashboardService($repository);
$this->assertSame(0, $service->pendingDeletionCount(180, 0, 7));
}
public function testReturnsZeroWhenWindowDaysIsZeroOrNegative(): void
{
$repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class);
$repository->expects($this->never())->method('countPendingDeletion');
$service = new UserLifecyclePolicyDashboardService($repository);
$this->assertSame(0, $service->pendingDeletionCount(180, 365, 0));
$this->assertSame(0, $service->pendingDeletionCount(180, 365, -1));
}
public function testDelegatesToRepositoryWhenAllPolicyValuesPositive(): void
{
$repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class);
$repository->expects($this->once())
->method('countPendingDeletion')
->with(365, 7)
->willReturn(42);
$service = new UserLifecyclePolicyDashboardService($repository);
$this->assertSame(42, $service->pendingDeletionCount(180, 365, 7));
}
public function testReturnsRepositoryCountVerbatim(): void
{
$repository = $this->createMock(UserLifecyclePolicyDashboardRepositoryInterface::class);
$repository->method('countPendingDeletion')->willReturn(0);
$service = new UserLifecyclePolicyDashboardService($repository);
$this->assertSame(0, $service->pendingDeletionCount(180, 365, 7));
}
}