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:
@@ -7,6 +7,7 @@ use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
use MintyPHP\Repository\Org\UserDepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
use MintyPHP\Repository\User\UserWriteRepository;
|
||||
use MintyPHP\Service\Auth\TenantSsoService;
|
||||
@@ -19,6 +20,7 @@ use MintyPHP\Service\User\UserApiWriteInputMapper;
|
||||
use MintyPHP\Service\User\UserAssignmentService;
|
||||
use MintyPHP\Service\User\UserAvatarService;
|
||||
use MintyPHP\Service\User\UserDirectoryGateway;
|
||||
use MintyPHP\Service\User\UserLifecyclePolicyDashboardService;
|
||||
use MintyPHP\Service\User\UserLifecycleRestoreService;
|
||||
use MintyPHP\Service\User\UserLifecycleService;
|
||||
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(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(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(
|
||||
$c->get(UserAccountService::class),
|
||||
$c->get(UserAssignmentService::class),
|
||||
|
||||
@@ -23,6 +23,8 @@ use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
|
||||
use MintyPHP\Service\Audit\NullAuditRecorder;
|
||||
use MintyPHP\Service\Audit\NullImportAudit;
|
||||
use MintyPHP\Service\Audit\NullUserLifecycleAudit;
|
||||
use MintyPHP\Service\Audit\NullUserLifecycleAuditDashboard;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
|
||||
$container = new AppContainer();
|
||||
@@ -85,6 +87,9 @@ if (!$container->has(AuditRecorderInterface::class)) {
|
||||
if (!$container->has(UserLifecycleAuditInterface::class)) {
|
||||
$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)) {
|
||||
$container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
24
core/Service/Audit/NullUserLifecycleAuditDashboard.php
Normal file
24
core/Service/Audit/NullUserLifecycleAuditDashboard.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
31
core/Service/Audit/UserLifecycleAuditDashboardInterface.php
Normal file
31
core/Service/Audit/UserLifecycleAuditDashboardInterface.php
Normal 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;
|
||||
}
|
||||
29
core/Service/User/UserLifecyclePolicyDashboardService.php
Normal file
29
core/Service/User/UserLifecyclePolicyDashboardService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use MintyPHP\Repository\Org\UserDepartmentRepository;
|
||||
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepository;
|
||||
use MintyPHP\Repository\User\UserLifecyclePolicyDashboardRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserListQueryRepository;
|
||||
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
@@ -23,6 +25,7 @@ class UserRepositoryFactory
|
||||
private ?UserTenantRepository $userTenantRepository = null;
|
||||
private ?UserRoleRepository $userRoleRepository = null;
|
||||
private ?UserDepartmentRepository $userDepartmentRepository = null;
|
||||
private ?UserLifecyclePolicyDashboardRepository $userLifecyclePolicyDashboardRepository = null;
|
||||
|
||||
public function createUserReadRepository(): UserReadRepositoryInterface
|
||||
{
|
||||
@@ -53,4 +56,12 @@ class UserRepositoryFactory
|
||||
{
|
||||
return $this->userDepartmentRepository ??= new UserDepartmentRepository();
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
*/
|
||||
public function createUserLifecyclePolicyDashboardRepository(): UserLifecyclePolicyDashboardRepositoryInterface
|
||||
{
|
||||
return $this->userLifecyclePolicyDashboardRepository ??= new UserLifecyclePolicyDashboardRepository();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,5 +1381,19 @@
|
||||
"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 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?"
|
||||
}
|
||||
|
||||
@@ -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 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?",
|
||||
"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?"
|
||||
}
|
||||
|
||||
@@ -25,12 +25,14 @@ use MintyPHP\Module\Audit\Service\ImportAuditService;
|
||||
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
|
||||
use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter;
|
||||
use MintyPHP\Module\Audit\Service\SystemAuditService;
|
||||
use MintyPHP\Module\Audit\Service\UserLifecycleAuditDashboardService;
|
||||
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Audit\ImportAuditInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
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(
|
||||
$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(
|
||||
$c->get(ImportAuditRunRepository::class)
|
||||
));
|
||||
@@ -71,6 +76,7 @@ final class AuditContainerRegistrar implements ContainerRegistrar
|
||||
// Core interface bindings — override null implementations
|
||||
$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(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(ApiAuditServiceInterface::class, static fn (AppContainer $c): ApiAuditServiceInterface => $c->get(ApiAuditService::class));
|
||||
$container->set(ApiSystemAuditReporterInterface::class, static fn (AppContainer $c): ApiSystemAuditReporterInterface => $c->get(ApiSystemAuditReporter::class));
|
||||
|
||||
@@ -278,6 +278,92 @@ class UserLifecycleAuditRepository
|
||||
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
|
||||
{
|
||||
if ($days <= 0) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,10 @@ use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\SettingsAuthorizationPolicy;
|
||||
use MintyPHP\Service\Audit\NullUserLifecycleAuditDashboard;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditDashboardInterface;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\User\UserLifecyclePolicyDashboardService;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
@@ -75,6 +78,135 @@ if ($request->isMethod('POST')) {
|
||||
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'));
|
||||
|
||||
$breadcrumbs = [
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* @var array $values
|
||||
* @var array $settings
|
||||
* @var bool $canUpdateSettings
|
||||
* @var array<int, array<string, mixed>> $kpiTiles
|
||||
* @var string $lastRunSummary
|
||||
* @var array<int, array<string, mixed>> $asideActions
|
||||
*/
|
||||
|
||||
use MintyPHP\Session;
|
||||
@@ -17,6 +20,9 @@ $userInactivityDeleteDaysDesc = $settings['user_inactivity_delete_days']['descri
|
||||
$canUpdateSettings = (bool) ($canUpdateSettings ?? false);
|
||||
$isReadOnly = !$canUpdateSettings;
|
||||
$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'] : [];
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$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');
|
||||
?>
|
||||
|
||||
<?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">
|
||||
<div class="grid">
|
||||
<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">
|
||||
<summary>
|
||||
<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>
|
||||
<div class="app-details-card-container">
|
||||
<blockquote data-variant="warning">
|
||||
@@ -111,6 +122,8 @@ $moduleLifecyclePanelSlots = is_array($moduleSlots['settings.user_lifecycle.pane
|
||||
<?php endforeach; ?>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
20
templates/partials/app-kpi-row.phtml
Normal file
20
templates/partials/app-kpi-row.phtml
Normal 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>
|
||||
@@ -28,6 +28,8 @@ final class AuditModuleIsolationContractTest extends TestCase
|
||||
'NullAuditRecorder.php',
|
||||
'NullImportAudit.php',
|
||||
'NullUserLifecycleAudit.php',
|
||||
'NullUserLifecycleAuditDashboard.php',
|
||||
'UserLifecycleAuditDashboardInterface.php',
|
||||
'UserLifecycleAuditInterface.php',
|
||||
];
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user