forked from fa/breadcrumb-the-shire
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\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),
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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\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 = [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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',
|
'NullAuditRecorder.php',
|
||||||
'NullImportAudit.php',
|
'NullImportAudit.php',
|
||||||
'NullUserLifecycleAudit.php',
|
'NullUserLifecycleAudit.php',
|
||||||
|
'NullUserLifecycleAuditDashboard.php',
|
||||||
|
'UserLifecycleAuditDashboardInterface.php',
|
||||||
'UserLifecycleAuditInterface.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