diff --git a/core/App/Container/Registrars/UserRegistrar.php b/core/App/Container/Registrars/UserRegistrar.php index a91d215..e727b10 100644 --- a/core/App/Container/Registrars/UserRegistrar.php +++ b/core/App/Container/Registrars/UserRegistrar.php @@ -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), diff --git a/core/App/registerContainer.php b/core/App/registerContainer.php index b58eaf3..1e1e7ae 100644 --- a/core/App/registerContainer.php +++ b/core/App/registerContainer.php @@ -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()); } diff --git a/core/Repository/User/UserLifecyclePolicyDashboardRepository.php b/core/Repository/User/UserLifecyclePolicyDashboardRepository.php new file mode 100644 index 0000000..2efadf1 --- /dev/null +++ b/core/Repository/User/UserLifecyclePolicyDashboardRepository.php @@ -0,0 +1,34 @@ + DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)', + (string) $lowerBoundDays, + (string) $deleteDays + ); + return (int) ($value ?? 0); + } +} diff --git a/core/Repository/User/UserLifecyclePolicyDashboardRepositoryInterface.php b/core/Repository/User/UserLifecyclePolicyDashboardRepositoryInterface.php new file mode 100644 index 0000000..578c8aa --- /dev/null +++ b/core/Repository/User/UserLifecyclePolicyDashboardRepositoryInterface.php @@ -0,0 +1,15 @@ + + */ + public function summaryByAction(int $days, string $status = 'success'): array; +} diff --git a/core/Service/User/UserLifecyclePolicyDashboardService.php b/core/Service/User/UserLifecyclePolicyDashboardService.php new file mode 100644 index 0000000..f2a5715 --- /dev/null +++ b/core/Service/User/UserLifecyclePolicyDashboardService.php @@ -0,0 +1,29 @@ +userLifecyclePolicyDashboardRepository->countPendingDeletion($deleteDays, $windowDays); + } +} diff --git a/core/Service/User/UserRepositoryFactory.php b/core/Service/User/UserRepositoryFactory.php index 8c56670..651491c 100644 --- a/core/Service/User/UserRepositoryFactory.php +++ b/core/Service/User/UserRepositoryFactory.php @@ -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(); + } } diff --git a/i18n/default_de.json b/i18n/default_de.json index 237db84..48db6f2 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -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?" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 8583382..27e9788 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -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?" } diff --git a/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php b/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php index d4b193c..3d1ce70 100644 --- a/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php +++ b/modules/audit/lib/Module/Audit/AuditContainerRegistrar.php @@ -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)); diff --git a/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php b/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php index da84dab..be9e5fb 100644 --- a/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php +++ b/modules/audit/lib/Module/Audit/Repository/UserLifecycleAuditRepository.php @@ -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 + */ + 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) { diff --git a/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php b/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php new file mode 100644 index 0000000..67bd239 --- /dev/null +++ b/modules/audit/lib/Module/Audit/Service/UserLifecycleAuditDashboardService.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php b/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php new file mode 100644 index 0000000..c999728 --- /dev/null +++ b/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditDashboardServiceTest.php @@ -0,0 +1,97 @@ +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') + ); + } +} diff --git a/pages/admin/settings/user-lifecycle().php b/pages/admin/settings/user-lifecycle().php index c3f98ec..5ca2402 100644 --- a/pages/admin/settings/user-lifecycle().php +++ b/pages/admin/settings/user-lifecycle().php @@ -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
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> $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> $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 = [ diff --git a/pages/admin/settings/user-lifecycle(default).phtml b/pages/admin/settings/user-lifecycle(default).phtml index 5978f91..e70def5 100644 --- a/pages/admin/settings/user-lifecycle(default).phtml +++ b/pages/admin/settings/user-lifecycle(default).phtml @@ -4,6 +4,9 @@ * @var array $values * @var array $settings * @var bool $canUpdateSettings + * @var array> $kpiTiles + * @var string $lastRunSummary + * @var array> $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'); ?> + +