1
0

First version of the security module

This commit is contained in:
2026-06-22 13:19:05 +02:00
parent 0392043ee3
commit 498afc7840
64 changed files with 7581 additions and 4 deletions

View File

@@ -6,7 +6,8 @@
"Bash(Remove-Item -Force \"modules/helpdesk/web/js/helpdesk-dashboard.js\")",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit -m ' *)"
"Bash(git commit -m ' *)",
"Bash(docker compose *)"
]
}
}

View File

@@ -31,14 +31,16 @@
"psr-4": {
"MintyPHP\\Tests\\": "tests/",
"MintyPHP\\Tests\\Module\\Audit\\": "modules/audit/tests/Module/Audit/",
"MintyPHP\\Tests\\Module\\Helpdesk\\": "modules/helpdesk/tests/Module/Helpdesk/"
"MintyPHP\\Tests\\Module\\Helpdesk\\": "modules/helpdesk/tests/Module/Helpdesk/",
"MintyPHP\\Tests\\Module\\Security\\": "modules/security/tests/Module/Security/"
}
},
"autoload": {
"psr-4": {
"MintyPHP\\": "core/",
"MintyPHP\\Module\\Audit\\": "modules/audit/lib/Module/Audit/",
"MintyPHP\\Module\\Helpdesk\\": "modules/helpdesk/lib/Module/Helpdesk/"
"MintyPHP\\Module\\Helpdesk\\": "modules/helpdesk/lib/Module/Helpdesk/",
"MintyPHP\\Module\\Security\\": "modules/security/lib/Module/Security/"
}
},
"scripts": {

View File

@@ -12,5 +12,5 @@
* Each entry must match a directory name under modules/ containing a module.php manifest.
*/
return [
'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center', 'helpdesk'],
'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center', 'helpdesk', 'security'],
];

112
modules/security/CLAUDE.md Normal file
View File

@@ -0,0 +1,112 @@
# CLAUDE.md — Security Module
Self-contained MintyPHP module. Namespace: `MintyPHP\Module\Security\*`. **Standalone**
`requires: []`, no dependency on helpdesk. All changes stay inside `modules/security/`.
After any manifest change (route, permission, slot): `docker compose exec php php bin/console module:sync`
---
## What it does
Lets the security officer run **security checks** for customers. A check is created via a wizard
(select customer → domain → software product), then tracked on a checklist workspace that combines:
1. A **fixed 7-step internal process** (`SecurityCheckProcess`, identical for every check), and
2. A **per-product technical checklist** defined as a reusable **template** (the product catalog is
owned by this module — the templates *are* the product list).
Overall status (`open``in_progress``completed`, plus manual `archived`) is **derived** from
checklist progress. Each step/item records who completed it and when (completion log).
---
## Architecture
```
lib/Module/Security/
SecurityAuthorizationPolicy.php — ability/permission constants + authorize()
SecurityContainerRegistrar.php — all DI bindings (add new services here)
Providers/SecurityLayoutProvider.php — resolves can_* flags for the sidebar
Repository/ — SQL only (tenant-scoped, prepared statements)
Service/
SecurityCheckProcess.php — fixed 7-step process + progress/status math (pure, no DI)
SecurityCheckService.php — check CRUD, saveState, status derivation, archive
SecurityCheckTemplateService.php— template CRUD + tech-schema validation/slugify
SecurityBcSettingsGateway.php — global BC connection settings (core settings table, encrypted)
SecurityOAuthTokenService.php — OAuth2 token acquire + encrypted cache
SecurityBcGateway.php — thin BC OData (ONLY customer-search + domains-for-customer)
SecurityReportPdfService.php — customer PDF report (Dompdf); pure context builder + render
pages/security/ — actions (*.php) + views (*.phtml)
checks/report($id).php — streams the customer report PDF (gated on steps 15 complete)
templates/aside-security-panel.phtml— sidebar navigation
templates/pdf/customer-report.phtml — customer report PDF template (hydrated by SecurityReportPdfService)
i18n/default_de.json + default_en.json — keep both in sync (identical key sets)
migrations/ — 003 SQL files (module:migrate)
web/css/security.css | web/js/ — module styles + runtime components + page scripts
tests/Module/Security/Service/ — 7 test files (happy path + edge case)
```
---
## Permissions (6)
All constants in `SecurityAuthorizationPolicy`:
| Constant | Key |
|---|---|
| `ABILITY_ACCESS` | `security.access` — gates the main-menu entry |
| `ABILITY_CHECKS_VIEW` | `security.checks.view` |
| `ABILITY_CHECKS_CREATE` | `security.checks.create` — create + edit the checklist |
| `ABILITY_CHECKS_MANAGE` | `security.checks.manage` — archive, delete |
| `ABILITY_TEMPLATES_MANAGE` | `security.templates.manage` |
| `ABILITY_SETTINGS_MANAGE` | `security.settings.manage` |
Gate every action with `Guard::requireAbilityOrForbidden(...)`; tenant scope enforced in repositories.
---
## DB schema
| Table | Key columns |
|---|---|
| `security_check_templates` | tenant_id, product_code (uniq), product_name, tech_schema_json, active, version |
| `security_checks` | tenant_id, debitor_no, domain_no, product_code, template_id, owner_user_id, status, process_state_json, tech_schema_snapshot_json, tech_state_json |
| `security_oauth_token_cache` | tenant_id, access_token_encrypted, expires_at |
`tech_schema_snapshot_json` freezes the template's checklist onto the check at creation time, so later
template edits never mutate in-flight checks. JSON state shapes:
- tech schema: `{version, items:[ {type:"section",label}, {type:"check",key,label,hint?} ]}`
- process_state: `{<stepKey>: {done, by, at, note, fields?}}`
- tech_state: `{<itemKey>: {done, by, at, note}}`
---
## Routes / pages
Main menu entry via the `aside.tab_panel` slot (icon `bi-shield-check`, permission `security.access`).
Pages: `security/checks` (list), `security/checks/create` (wizard), `security/checks/edit/{id}`
(workspace), `security/checks/report/{id}` (streams the customer PDF report),
`security/templates` (+create/edit/{id}), `security/settings`, plus data endpoints
`security/checks-data`, `security/templates-data`, `security/customer-search-data`,
`security/customer-domains-data`, `security/settings/test-connection-data`.
## JS runtime components
`data-app-component` on the container: `security-customer-domain-select` (wizard domain picker),
`security-check-workspace` (live progress), `security-template-schema-editor` (checklist editor),
`security-settings` (auth-mode toggle + connection test). List pages load their own
`web/js/pages/*-index.js` via `<script type="module">`.
---
## Tests
`docker compose exec php vendor/bin/phpunit modules/security/tests/`
Pattern: mock repositories, `->willReturn(...)` / `->willReturnCallback(...)` to capture args
(avoid `->with()`). Every new/changed Service or Gateway needs happy path + edge case.
## Deferred (not built)
Internal report PDF (the customer report is built — see `SecurityReportPdfService`),
per-tenant BC connection overrides, per-step assignment, revision history.

View File

@@ -0,0 +1,176 @@
{
"Security": "Security",
"Security checks": "Security Checks",
"Security check": "Security Check",
"Security settings": "Security-Einstellungen",
"Check templates": "Check-Vorlagen",
"Operations": "Durchführung",
"Administration": "Verwaltung",
"Settings": "Einstellungen",
"Home": "Start",
"Customer": "Kunde",
"Domain": "Domain",
"Software product": "Software-Produkt",
"Owner": "Verantwortlich",
"Status": "Status",
"Created": "Erstellt",
"Updated": "Aktualisiert",
"Product": "Produkt",
"Code": "Code",
"Checklist items": "Checklisten-Punkte",
"Active": "Aktiv",
"Yes": "Ja",
"No": "Nein",
"Search": "Suche",
"Progress": "Fortschritt",
"Open": "Offen",
"In progress": "In Bearbeitung",
"Completed": "Abgeschlossen",
"Archived": "Archiviert",
"Complete": "Vollständig",
"Done": "Erledigt",
"New security check": "Neuer Security Check",
"Create security check": "Security Check anlegen",
"Select customer, domain and product": "Kunde, Domain und Produkt auswählen",
"Choose the customer, domain and software product this security check applies to.": "Wählen Sie Kunde, Domain und Software-Produkt für diesen Security Check.",
"Search by name or number...": "Nach Name oder Nummer suchen...",
"No customers found": "Keine Kunden gefunden",
"Search failed": "Suche fehlgeschlagen",
"Select a customer first...": "Bitte zuerst einen Kunden auswählen...",
"Loading domains...": "Domains werden geladen...",
"Please select...": "Bitte auswählen...",
"No domains found for this customer": "Keine Domains für diesen Kunden gefunden",
"No domains found for this customer. Enter domain name manually:": "Keine Domains für diesen Kunden gefunden. Domain manuell eingeben:",
"Failed to load domains": "Domains konnten nicht geladen werden",
"Domain name (e.g. example.de)": "Domain-Name (z. B. example.de)",
"URL (optional, e.g. https://example.de)": "URL (optional, z. B. https://example.de)",
"No check templates found. Create a check template first.": "Keine Check-Vorlagen gefunden. Bitte zuerst eine Vorlage anlegen.",
"Back to list": "Zurück zur Liste",
"Cancel": "Abbrechen",
"Save": "Speichern",
"Save & close": "Speichern & schließen",
"Note (optional)": "Notiz (optional)",
"Danger zone": "Gefahrenzone",
"Archive check": "Check archivieren",
"Reopen check": "Check wieder öffnen",
"Delete check": "Check löschen",
"Delete this security check? This cannot be undone.": "Diesen Security Check löschen? Dies kann nicht rückgängig gemacht werden.",
"This security check is archived and read-only.": "Dieser Security Check ist archiviert und schreibgeschützt.",
"This product has no technical checklist items defined yet.": "Für dieses Produkt sind noch keine technischen Checklisten-Punkte definiert.",
"%d of %d completed": "%d von %d erledigt",
"Order received": "Beauftragung",
"Gather information": "Informationsbeschaffung",
"Scheduling": "Terminfindung",
"Internal blocker appointment": "Interner Block-Termin",
"Perform security check": "Security Check durchführen",
"Create report & notify customer": "Protokoll/Bericht erstellen & Kunden benachrichtigen",
"The security check was sold for one or more customer projects; sales briefs the security officer with all details.": "Der Security Check wurde für ein oder mehrere Projekte des Kunden verkauft; der Verkäufer übergibt dem Security-Beauftragten alle Details.",
"Collect all relevant information from Customer Care / the project lead.": "Alle wichtigen Informationen vom Customer Care bzw. Projektleiter einholen.",
"Agree internally and with the customer on the check date. Only the 23h go-live window is relevant to the customer.": "Den Termin intern und mit dem Kunden abstimmen. Für den Kunden ist nur das 23-stündige Live-Stellungs-Fenster relevant.",
"Create a 12 day internal blocker; invite Customer Care, Customizing and Sales.": "Einen internen Blocker-Termin (12 Tage) erstellen; Customer Care, Customizing und Verkäufer einladen.",
"Work through the product-specific technical checklist.": "Die produktspezifische technische Checkliste abarbeiten.",
"Create the report for the customer and notify them.": "Den Bericht für den Kunden erstellen und ihn benachrichtigen.",
"Technical contact at customer": "Techn. Ansprechpartner beim Kunden",
"Deployment location & type": "Deployment-Ort und -Art",
"Technical system information": "Technische Informationen über das System",
"External systems / API endpoints (exact URLs)": "Externe Systeme / API-Endpoints (genaue URLs)",
"Special notes": "Besonderheiten",
"Go-live window start": "Go-live-Fenster Beginn",
"Go-live window end": "Go-live-Fenster Ende",
"Blocker start": "Block-Termin Beginn",
"Blocker end": "Block-Termin Ende",
"New template": "Neue Vorlage",
"New check template": "Neue Check-Vorlage",
"Create a check template": "Check-Vorlage anlegen",
"A template represents one software product and its technical checklist. You can add checklist items after creating it.": "Eine Vorlage steht für ein Software-Produkt und dessen technische Checkliste. Checklisten-Punkte können nach dem Anlegen hinzugefügt werden.",
"Product code": "Produkt-Code",
"Product name": "Produktname",
"e.g. intranet, mysyde-cms": "z. B. intranet, mysyde-cms",
"e.g. Intranet, Mysyde CMS": "z. B. Intranet, Mysyde CMS",
"Create template": "Vorlage anlegen",
"Edit template": "Vorlage bearbeiten",
"The product code cannot be changed after creation.": "Der Produkt-Code kann nach dem Anlegen nicht mehr geändert werden.",
"Active (selectable when creating a security check)": "Aktiv (beim Anlegen eines Security Checks auswählbar)",
"Technical checklist": "Technische Checkliste",
"Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.": "Definieren Sie die produktspezifischen technischen Checks. Abschnitte gruppieren die Punkte. Die Punkte werden beim Anlegen eines Security Checks eingefroren.",
"Section": "Abschnitt",
"Check item": "Prüfpunkt",
"Add section": "Abschnitt hinzufügen",
"Add check item": "Prüfpunkt hinzufügen",
"Remove": "Entfernen",
"Move up": "Nach oben",
"Move down": "Nach unten",
"Section title": "Abschnitts-Titel",
"Check item label": "Bezeichnung des Prüfpunkts",
"Hint (optional)": "Hinweis (optional)",
"Markdown guidance (optional) — shown in the checklist": "Markdown-Hinweis (optional) — wird in der Checkliste angezeigt",
"Guidance": "Hinweise",
"No checklist items yet. Add a check item to get started.": "Noch keine Checklisten-Punkte. Fügen Sie einen Prüfpunkt hinzu.",
"Business Central connection": "Business-Central-Verbindung",
"Authentication mode": "Authentifizierungs-Modus",
"Basic authentication": "Basic-Authentifizierung",
"OAuth2 (client credentials)": "OAuth2 (Client Credentials)",
"OData base URL": "OData-Basis-URL",
"Company name": "Firmenname",
"Username": "Benutzername",
"Password": "Passwort",
"Token endpoint": "Token-Endpoint",
"Directory (tenant) ID": "Verzeichnis-(Tenant-)ID",
"Client ID": "Client-ID",
"Client secret": "Client-Secret",
"•••••••• (leave blank to keep)": "•••••••• (leer lassen, um beizubehalten)",
"Connection not fully configured": "Verbindung nicht vollständig konfiguriert",
"Test connection": "Verbindung testen",
"Save your settings first, then test the connection to Business Central.": "Speichern Sie zuerst Ihre Einstellungen und testen Sie dann die Verbindung zu Business Central.",
"Testing...": "Test läuft...",
"Connection successful": "Verbindung erfolgreich",
"Connection failed": "Verbindung fehlgeschlagen",
"Settings saved": "Einstellungen gespeichert",
"OData Base URL must use HTTPS": "Die OData-Basis-URL muss HTTPS verwenden",
"Token endpoint must use HTTPS": "Der Token-Endpoint muss HTTPS verwenden",
"Form expired, please try again": "Formular abgelaufen, bitte erneut versuchen",
"Permission denied": "Zugriff verweigert",
"No tenant context": "Kein Mandanten-Kontext",
"Please select a customer": "Bitte einen Kunden auswählen",
"Please select a domain": "Bitte eine Domain auswählen",
"Please select a software product": "Bitte ein Software-Produkt auswählen",
"Selected check template not found": "Ausgewählte Check-Vorlage nicht gefunden",
"Security check could not be created": "Security Check konnte nicht angelegt werden",
"Security check created": "Security Check angelegt",
"Security check not found": "Security Check nicht gefunden",
"Security check saved": "Security Check gespeichert",
"Saved. Fill in all required fields to complete the affected steps.": "Gespeichert. Bitte alle Pflichtfelder ausfüllen, um die betreffenden Schritte abzuschließen.",
"Fill in all required fields to complete this step.": "Bitte alle Pflichtfelder ausfüllen, um diesen Schritt abzuschließen.",
"Security check deleted": "Security Check gelöscht",
"Security check archived": "Security Check archiviert",
"Security check reopened": "Security Check wieder geöffnet",
"Archived checks cannot be edited": "Archivierte Checks können nicht bearbeitet werden",
"This security check cannot be edited": "Dieser Security Check kann nicht bearbeitet werden",
"Could not save the security check": "Security Check konnte nicht gespeichert werden",
"Could not update the status": "Status konnte nicht aktualisiert werden",
"Product code cannot be empty": "Produkt-Code darf nicht leer sein",
"Product code is invalid": "Produkt-Code ist ungültig",
"A template with this product code already exists": "Eine Vorlage mit diesem Produkt-Code existiert bereits",
"Product name cannot be empty": "Produktname darf nicht leer sein",
"Template could not be created": "Vorlage konnte nicht angelegt werden",
"Template could not be updated": "Vorlage konnte nicht aktualisiert werden",
"Template not found": "Vorlage nicht gefunden",
"Template created": "Vorlage angelegt",
"Template saved": "Vorlage gespeichert",
"Maximum %d items allowed": "Maximal %d Punkte erlaubt",
"Item %d has an invalid type": "Punkt %d hat einen ungültigen Typ",
"Item %d is missing a label": "Punkt %d fehlt eine Bezeichnung",
"Item %d has an invalid key": "Punkt %d hat einen ungültigen Schlüssel",
"Could not encode the checklist": "Die Checkliste konnte nicht kodiert werden",
"Checklist could not be saved": "Die Checkliste konnte nicht gespeichert werden",
"Generate PDF customer report": "PDF-Kundenbericht erstellen",
"Opens in a new tab and reflects the last saved state.": "Öffnet sich in einem neuen Tab und gibt den zuletzt gespeicherten Stand wieder.",
"Complete and save all previous steps to enable the customer report.": "Alle vorherigen Schritte abschließen und speichern, um den Kundenbericht freizuschalten.",
"Complete all previous steps before generating the customer report.": "Schließen Sie alle vorherigen Schritte ab, bevor Sie den Kundenbericht erstellen.",
"Security check report": "Sicherheitscheck-Bericht",
"Generated on": "Erstellt am",
"Result": "Ergebnis",
"Process": "Ablauf",
"Not completed": "Nicht abgeschlossen",
"%d of %d checks completed": "%d von %d Prüfungen abgeschlossen"
}

View File

@@ -0,0 +1,176 @@
{
"Security": "Security",
"Security checks": "Security checks",
"Security check": "Security check",
"Security settings": "Security settings",
"Check templates": "Check templates",
"Operations": "Operations",
"Administration": "Administration",
"Settings": "Settings",
"Home": "Home",
"Customer": "Customer",
"Domain": "Domain",
"Software product": "Software product",
"Owner": "Owner",
"Status": "Status",
"Created": "Created",
"Updated": "Updated",
"Product": "Product",
"Code": "Code",
"Checklist items": "Checklist items",
"Active": "Active",
"Yes": "Yes",
"No": "No",
"Search": "Search",
"Progress": "Progress",
"Open": "Open",
"In progress": "In progress",
"Completed": "Completed",
"Archived": "Archived",
"Complete": "Complete",
"Done": "Done",
"New security check": "New security check",
"Create security check": "Create security check",
"Select customer, domain and product": "Select customer, domain and product",
"Choose the customer, domain and software product this security check applies to.": "Choose the customer, domain and software product this security check applies to.",
"Search by name or number...": "Search by name or number...",
"No customers found": "No customers found",
"Search failed": "Search failed",
"Select a customer first...": "Select a customer first...",
"Loading domains...": "Loading domains...",
"Please select...": "Please select...",
"No domains found for this customer": "No domains found for this customer",
"No domains found for this customer. Enter domain name manually:": "No domains found for this customer. Enter domain name manually:",
"Failed to load domains": "Failed to load domains",
"Domain name (e.g. example.de)": "Domain name (e.g. example.de)",
"URL (optional, e.g. https://example.de)": "URL (optional, e.g. https://example.de)",
"No check templates found. Create a check template first.": "No check templates found. Create a check template first.",
"Back to list": "Back to list",
"Cancel": "Cancel",
"Save": "Save",
"Save & close": "Save & close",
"Note (optional)": "Note (optional)",
"Danger zone": "Danger zone",
"Archive check": "Archive check",
"Reopen check": "Reopen check",
"Delete check": "Delete check",
"Delete this security check? This cannot be undone.": "Delete this security check? This cannot be undone.",
"This security check is archived and read-only.": "This security check is archived and read-only.",
"This product has no technical checklist items defined yet.": "This product has no technical checklist items defined yet.",
"%d of %d completed": "%d of %d completed",
"Order received": "Order received",
"Gather information": "Gather information",
"Scheduling": "Scheduling",
"Internal blocker appointment": "Internal blocker appointment",
"Perform security check": "Perform security check",
"Create report & notify customer": "Create report & notify customer",
"The security check was sold for one or more customer projects; sales briefs the security officer with all details.": "The security check was sold for one or more customer projects; sales briefs the security officer with all details.",
"Collect all relevant information from Customer Care / the project lead.": "Collect all relevant information from Customer Care / the project lead.",
"Agree internally and with the customer on the check date. Only the 23h go-live window is relevant to the customer.": "Agree internally and with the customer on the check date. Only the 23h go-live window is relevant to the customer.",
"Create a 12 day internal blocker; invite Customer Care, Customizing and Sales.": "Create a 12 day internal blocker; invite Customer Care, Customizing and Sales.",
"Work through the product-specific technical checklist.": "Work through the product-specific technical checklist.",
"Create the report for the customer and notify them.": "Create the report for the customer and notify them.",
"Technical contact at customer": "Technical contact at customer",
"Deployment location & type": "Deployment location & type",
"Technical system information": "Technical system information",
"External systems / API endpoints (exact URLs)": "External systems / API endpoints (exact URLs)",
"Special notes": "Special notes",
"Go-live window start": "Go-live window start",
"Go-live window end": "Go-live window end",
"Blocker start": "Blocker start",
"Blocker end": "Blocker end",
"New template": "New template",
"New check template": "New check template",
"Create a check template": "Create a check template",
"A template represents one software product and its technical checklist. You can add checklist items after creating it.": "A template represents one software product and its technical checklist. You can add checklist items after creating it.",
"Product code": "Product code",
"Product name": "Product name",
"e.g. intranet, mysyde-cms": "e.g. intranet, mysyde-cms",
"e.g. Intranet, Mysyde CMS": "e.g. Intranet, Mysyde CMS",
"Create template": "Create template",
"Edit template": "Edit template",
"The product code cannot be changed after creation.": "The product code cannot be changed after creation.",
"Active (selectable when creating a security check)": "Active (selectable when creating a security check)",
"Technical checklist": "Technical checklist",
"Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.": "Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.",
"Section": "Section",
"Check item": "Check item",
"Add section": "Add section",
"Add check item": "Add check item",
"Remove": "Remove",
"Move up": "Move up",
"Move down": "Move down",
"Section title": "Section title",
"Check item label": "Check item label",
"Hint (optional)": "Hint (optional)",
"Markdown guidance (optional) — shown in the checklist": "Markdown guidance (optional) — shown in the checklist",
"Guidance": "Guidance",
"No checklist items yet. Add a check item to get started.": "No checklist items yet. Add a check item to get started.",
"Business Central connection": "Business Central connection",
"Authentication mode": "Authentication mode",
"Basic authentication": "Basic authentication",
"OAuth2 (client credentials)": "OAuth2 (client credentials)",
"OData base URL": "OData base URL",
"Company name": "Company name",
"Username": "Username",
"Password": "Password",
"Token endpoint": "Token endpoint",
"Directory (tenant) ID": "Directory (tenant) ID",
"Client ID": "Client ID",
"Client secret": "Client secret",
"•••••••• (leave blank to keep)": "•••••••• (leave blank to keep)",
"Connection not fully configured": "Connection not fully configured",
"Test connection": "Test connection",
"Save your settings first, then test the connection to Business Central.": "Save your settings first, then test the connection to Business Central.",
"Testing...": "Testing...",
"Connection successful": "Connection successful",
"Connection failed": "Connection failed",
"Settings saved": "Settings saved",
"OData Base URL must use HTTPS": "OData Base URL must use HTTPS",
"Token endpoint must use HTTPS": "Token endpoint must use HTTPS",
"Form expired, please try again": "Form expired, please try again",
"Permission denied": "Permission denied",
"No tenant context": "No tenant context",
"Please select a customer": "Please select a customer",
"Please select a domain": "Please select a domain",
"Please select a software product": "Please select a software product",
"Selected check template not found": "Selected check template not found",
"Security check could not be created": "Security check could not be created",
"Security check created": "Security check created",
"Security check not found": "Security check not found",
"Security check saved": "Security check saved",
"Saved. Fill in all required fields to complete the affected steps.": "Saved. Fill in all required fields to complete the affected steps.",
"Fill in all required fields to complete this step.": "Fill in all required fields to complete this step.",
"Security check deleted": "Security check deleted",
"Security check archived": "Security check archived",
"Security check reopened": "Security check reopened",
"Archived checks cannot be edited": "Archived checks cannot be edited",
"This security check cannot be edited": "This security check cannot be edited",
"Could not save the security check": "Could not save the security check",
"Could not update the status": "Could not update the status",
"Product code cannot be empty": "Product code cannot be empty",
"Product code is invalid": "Product code is invalid",
"A template with this product code already exists": "A template with this product code already exists",
"Product name cannot be empty": "Product name cannot be empty",
"Template could not be created": "Template could not be created",
"Template could not be updated": "Template could not be updated",
"Template not found": "Template not found",
"Template created": "Template created",
"Template saved": "Template saved",
"Maximum %d items allowed": "Maximum %d items allowed",
"Item %d has an invalid type": "Item %d has an invalid type",
"Item %d is missing a label": "Item %d is missing a label",
"Item %d has an invalid key": "Item %d has an invalid key",
"Could not encode the checklist": "Could not encode the checklist",
"Checklist could not be saved": "Checklist could not be saved",
"Generate PDF customer report": "Generate PDF customer report",
"Opens in a new tab and reflects the last saved state.": "Opens in a new tab and reflects the last saved state.",
"Complete and save all previous steps to enable the customer report.": "Complete and save all previous steps to enable the customer report.",
"Complete all previous steps before generating the customer report.": "Complete all previous steps before generating the customer report.",
"Security check report": "Security check report",
"Generated on": "Generated on",
"Result": "Result",
"Process": "Process",
"Not completed": "Not completed",
"%d of %d checks completed": "%d of %d checks completed"
}

View File

@@ -0,0 +1,43 @@
<?php
namespace MintyPHP\Module\Security\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationService;
/**
* Resolves the can_* navigation flags for the Security sidebar panel.
*
* Merged into $layoutNav under the 'security.nav' key and consumed by
* templates/aside-security-panel.phtml.
*/
final class SecurityLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
$userId = (int) ($session['user']['id'] ?? 0);
if ($userId <= 0) {
return ['security.nav' => []];
}
try {
$authorizationService = $container->get(AuthorizationService::class);
$actorContext = ['actor_user_id' => $userId];
$canViewChecks = $authorizationService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW, $actorContext)->isAllowed();
$canManageTemplates = $authorizationService->authorize(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE, $actorContext)->isAllowed();
$canManageSettings = $authorizationService->authorize(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE, $actorContext)->isAllowed();
} catch (\Throwable) {
$canViewChecks = false;
$canManageTemplates = false;
$canManageSettings = false;
}
return ['security.nav' => [
'can_view_checks' => $canViewChecks,
'can_manage_templates' => $canManageTemplates,
'can_manage_settings' => $canManageSettings,
]];
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace MintyPHP\Module\Security\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
/**
* SQL access for security_checks. Tenant-scoped, prepared statements only.
*/
class SecurityCheckRepository
{
/**
* @param array<string, mixed> $data
* @return int|null Inserted ID or null on failure
*/
public function insert(int $tenantId, array $data): ?int
{
$result = DB::insert(
'INSERT INTO security_checks'
. ' (tenant_id, debitor_no, debitor_name, domain_no, domain_url, product_code, product_name,'
. ' template_id, owner_user_id, status, process_state_json, tech_schema_snapshot_json, tech_state_json, created_by)'
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(string) $tenantId,
(string) ($data['debitor_no'] ?? ''),
(string) ($data['debitor_name'] ?? ''),
(string) ($data['domain_no'] ?? ''),
(string) ($data['domain_url'] ?? ''),
(string) ($data['product_code'] ?? ''),
(string) ($data['product_name'] ?? ''),
isset($data['template_id']) ? (string) ((int) $data['template_id']) : null,
isset($data['owner_user_id']) ? (string) ((int) $data['owner_user_id']) : null,
(string) ($data['status'] ?? 'open'),
$data['process_state_json'] ?? null,
$data['tech_schema_snapshot_json'] ?? null,
$data['tech_state_json'] ?? null,
(string) ((int) ($data['created_by'] ?? 0))
);
return $result === false ? null : (int) $result;
}
public function findById(int $tenantId, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'SELECT c.*, uo.display_name AS owner_name, uc.display_name AS created_by_name, uu.display_name AS updated_by_name'
. ' FROM security_checks c'
. ' LEFT JOIN users uo ON uo.id = c.owner_user_id'
. ' LEFT JOIN users uc ON uc.id = c.created_by'
. ' LEFT JOIN users uu ON uu.id = c.updated_by'
. ' WHERE c.id = ? AND c.tenant_id = ? LIMIT 1',
(string) $id,
(string) $tenantId
);
return $this->normalizeRow($row);
}
/**
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$status = trim((string) ($filters['status'] ?? ''));
$view = trim((string) ($filters['view'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'debitor_name', 'domain_url', 'product_name', 'status', 'created_at', 'updated_at'],
'created_at',
'desc'
);
$where = ['c.tenant_id = ?'];
$params = [(string) $tenantId];
RepoQuery::addLikeFilter($where, $params, ['c.debitor_name', 'c.debitor_no', 'c.domain_url', 'c.product_name'], $search);
if ($status !== '' && $status !== 'all') {
$where[] = 'c.status = ?';
$params[] = $status;
} elseif ($view === 'active') {
$where[] = "c.status IN ('open', 'in_progress')";
} elseif ($view === 'done') {
$where[] = "c.status IN ('completed', 'archived')";
}
$whereSql = ' WHERE ' . implode(' AND ', $where);
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM security_checks c' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'SELECT c.*, uo.display_name AS owner_name, uc.display_name AS created_by_name'
. ' FROM security_checks c'
. ' LEFT JOIN users uo ON uo.id = c.owner_user_id'
. ' LEFT JOIN users uc ON uc.id = c.created_by'
. $whereSql
. sprintf(' ORDER BY c.`%s` %s LIMIT ? OFFSET ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
/**
* Active users in the tenant, for owner assignment. Ordered by display name.
*
* @return list<array{id: int, display_name: string}>
*/
public function listTenantUsers(int $tenantId): array
{
$rows = DB::select(
'SELECT u.id, u.display_name FROM users u'
. ' JOIN user_tenants ut ON ut.user_id = u.id'
. ' WHERE ut.tenant_id = ? AND u.active = 1'
. ' ORDER BY u.display_name ASC',
(string) $tenantId
);
$users = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$flat = $this->normalizeRow($row);
if ($flat === null) {
continue;
}
$id = (int) ($flat['id'] ?? 0);
if ($id > 0) {
$users[] = ['id' => $id, 'display_name' => (string) ($flat['display_name'] ?? '')];
}
}
}
return $users;
}
/**
* @param array<string, mixed> $data
*/
public function updateState(int $tenantId, int $id, array $data): bool
{
$result = DB::update(
'UPDATE security_checks SET process_state_json = ?, tech_state_json = ?, status = ?, updated_by = ?'
. ' WHERE id = ? AND tenant_id = ?',
$data['process_state_json'] ?? null,
$data['tech_state_json'] ?? null,
(string) ($data['status'] ?? 'open'),
(string) ((int) ($data['updated_by'] ?? 0)),
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function updateStatus(int $tenantId, int $id, string $status, int $updatedBy): bool
{
$result = DB::update(
'UPDATE security_checks SET status = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$status,
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function deleteById(int $tenantId, int $id): bool
{
if ($id <= 0) {
return false;
}
return (bool) DB::delete(
'DELETE FROM security_checks WHERE id = ? AND tenant_id = ?',
(string) $id,
(string) $tenantId
);
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$item = array_merge($item, $value);
} else {
$item[$key] = $value;
}
}
return isset($item['id']) ? $item : null;
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace MintyPHP\Module\Security\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
/**
* SQL access for security_check_templates. Tenant-scoped, prepared statements only.
*/
class SecurityCheckTemplateRepository
{
/**
* @param array<string, mixed> $data
* @return int|null Inserted ID or null on failure
*/
public function insert(int $tenantId, array $data): ?int
{
$result = DB::insert(
'INSERT INTO security_check_templates'
. ' (tenant_id, product_code, product_name, tech_schema_json, active, version, created_by, updated_by)'
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(string) $tenantId,
(string) ($data['product_code'] ?? ''),
(string) ($data['product_name'] ?? ''),
$data['tech_schema_json'] ?? null,
(string) ((int) ($data['active'] ?? 1)),
(string) ((int) ($data['version'] ?? 1)),
(string) ((int) ($data['created_by'] ?? 0)),
(string) ((int) ($data['created_by'] ?? 0))
);
return $result === false ? null : (int) $result;
}
public function findById(int $tenantId, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'SELECT t.*, uc.display_name AS created_by_name, uu.display_name AS updated_by_name'
. ' FROM security_check_templates t'
. ' LEFT JOIN users uc ON uc.id = t.created_by'
. ' LEFT JOIN users uu ON uu.id = t.updated_by'
. ' WHERE t.id = ? AND t.tenant_id = ? LIMIT 1',
(string) $id,
(string) $tenantId
);
return $this->normalizeRow($row);
}
public function findByCode(int $tenantId, string $code): ?array
{
$code = trim($code);
if ($code === '') {
return null;
}
$row = DB::selectOne(
'SELECT * FROM security_check_templates WHERE tenant_id = ? AND product_code = ? LIMIT 1',
(string) $tenantId,
$code
);
return $this->normalizeRow($row);
}
public function codeExists(int $tenantId, string $code, int $exceptId = 0): bool
{
$code = trim($code);
if ($code === '') {
return false;
}
$count = (int) (DB::selectValue(
'SELECT COUNT(*) FROM security_check_templates WHERE tenant_id = ? AND product_code = ? AND id <> ?',
(string) $tenantId,
$code,
(string) $exceptId
) ?? 0);
return $count > 0;
}
/**
* Active templates for the wizard product picker.
*
* @return list<array<string, mixed>>
*/
public function listActive(int $tenantId): array
{
$rows = DB::select(
'SELECT id, product_code, product_name, tech_schema_json FROM security_check_templates'
. ' WHERE tenant_id = ? AND active = 1 ORDER BY product_name ASC',
(string) $tenantId
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return $normalized;
}
/**
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$active = trim((string) ($filters['active'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'product_code', 'product_name', 'active', 'version', 'updated_at', 'created_at'],
'product_name',
'asc'
);
$where = ['t.tenant_id = ?'];
$params = [(string) $tenantId];
RepoQuery::addLikeFilter($where, $params, ['t.product_code', 't.product_name'], $search);
if ($active === '1' || $active === '0') {
$where[] = 't.active = ?';
$params[] = $active;
}
$whereSql = ' WHERE ' . implode(' AND ', $where);
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM security_check_templates t' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'SELECT t.*, uu.display_name AS updated_by_name'
. ' FROM security_check_templates t'
. ' LEFT JOIN users uu ON uu.id = t.updated_by'
. $whereSql
. sprintf(' ORDER BY t.`%s` %s LIMIT ? OFFSET ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public function updateMeta(int $tenantId, int $id, string $name, bool $active, int $updatedBy): bool
{
$result = DB::update(
'UPDATE security_check_templates SET product_name = ?, active = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$name,
(string) ($active ? 1 : 0),
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function updateSchema(int $tenantId, int $id, string $schemaJson, int $version, int $updatedBy): bool
{
$result = DB::update(
'UPDATE security_check_templates SET tech_schema_json = ?, version = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$schemaJson,
(string) $version,
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
// JOIN queries return nested arrays keyed by table name plus scalar aliased
// columns — merge sub-arrays and copy scalars (same convention as core repos).
$item = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$item = array_merge($item, $value);
} else {
$item[$key] = $value;
}
}
return isset($item['id']) ? $item : null;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace MintyPHP\Module\Security\Repository;
use MintyPHP\DB;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
/**
* OAuth2 token cache for the Security BC gateway (security_oauth_token_cache).
*
* Tokens are stored encrypted and tenant-scoped.
*/
class SecurityTokenRepository
{
public function __construct(
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
) {
}
public function findValidToken(int $tenantId): ?string
{
if ($tenantId <= 0) {
return null;
}
$result = DB::selectOne(
'SELECT access_token_encrypted FROM security_oauth_token_cache'
. ' WHERE tenant_id = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
(string) $tenantId
);
$row = is_array($result) ? ($result['security_oauth_token_cache'] ?? $result) : null;
if (!is_array($row) || !isset($row['access_token_encrypted'])) {
return null;
}
$encrypted = trim((string) $row['access_token_encrypted']);
if ($encrypted === '') {
return null;
}
try {
return $this->settingsCryptoGateway->decryptString($encrypted);
} catch (\Throwable) {
return null;
}
}
public function storeToken(int $tenantId, string $accessToken, \DateTimeInterface $expiresAt): bool
{
if ($tenantId <= 0 || $accessToken === '') {
return false;
}
try {
$encrypted = $this->settingsCryptoGateway->encryptString($accessToken);
} catch (\Throwable) {
return false;
}
DB::delete('DELETE FROM security_oauth_token_cache WHERE tenant_id = ?', (string) $tenantId);
return (bool) DB::insert(
'INSERT INTO security_oauth_token_cache (tenant_id, access_token_encrypted, expires_at) VALUES (?, ?, ?)',
(string) $tenantId,
$encrypted,
$expiresAt->format('Y-m-d H:i:s')
);
}
/**
* Clear all cached tokens — used when the global BC connection changes.
*
* @api Called from the Security settings page on connection change.
*/
public function deleteAll(): bool
{
return (bool) DB::delete('DELETE FROM security_oauth_token_cache WHERE 1=1');
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace MintyPHP\Module\Security;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use MintyPHP\Service\Access\PermissionService;
/**
* Authorization policy for the Security module.
*
* Maps module abilities to permission keys resolved via the core RBAC
* PermissionService. Registered by the core AuthorizationService through the
* module manifest's authorization_policies.
*
* @api Ability/permission constants are referenced from Security page actions.
*/
final class SecurityAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ACCESS = 'security.access';
public const ABILITY_CHECKS_VIEW = 'security.checks.view';
public const ABILITY_CHECKS_CREATE = 'security.checks.create';
public const ABILITY_CHECKS_MANAGE = 'security.checks.manage';
public const ABILITY_TEMPLATES_MANAGE = 'security.templates.manage';
public const ABILITY_SETTINGS_MANAGE = 'security.settings.manage';
public const PERMISSION_ACCESS = 'security.access';
public const PERMISSION_CHECKS_VIEW = 'security.checks.view';
public const PERMISSION_CHECKS_CREATE = 'security.checks.create';
public const PERMISSION_CHECKS_MANAGE = 'security.checks.manage';
public const PERMISSION_TEMPLATES_MANAGE = 'security.templates.manage';
public const PERMISSION_SETTINGS_MANAGE = 'security.settings.manage';
public function __construct(
private readonly PermissionService $permissionService
) {
}
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ACCESS,
self::ABILITY_CHECKS_VIEW,
self::ABILITY_CHECKS_CREATE,
self::ABILITY_CHECKS_MANAGE,
self::ABILITY_TEMPLATES_MANAGE,
self::ABILITY_SETTINGS_MANAGE,
], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
$permissionKey = match ($ability) {
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
self::ABILITY_CHECKS_VIEW => self::PERMISSION_CHECKS_VIEW,
self::ABILITY_CHECKS_CREATE => self::PERMISSION_CHECKS_CREATE,
self::ABILITY_CHECKS_MANAGE => self::PERMISSION_CHECKS_MANAGE,
self::ABILITY_TEMPLATES_MANAGE => self::PERMISSION_TEMPLATES_MANAGE,
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
default => null,
};
if ($permissionKey === null) {
return AuthorizationDecision::deny(403, 'forbidden');
}
if (!$this->permissionService->userHas($actorUserId, $permissionKey)) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace MintyPHP\Module\Security;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
use MintyPHP\Module\Security\Service\SecurityBcGateway;
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Tenant\TenantLogoService;
final class SecurityContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->set(SecurityAuthorizationPolicy::class, static fn (AppContainer $c): SecurityAuthorizationPolicy => new SecurityAuthorizationPolicy(
$c->get(PermissionService::class)
));
// --- Thin BC layer ---
$container->set(SecurityBcSettingsGateway::class, static fn (AppContainer $c): SecurityBcSettingsGateway => new SecurityBcSettingsGateway(
$c->get(SettingsMetadataGateway::class),
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
$container->set(SecurityTokenRepository::class, static fn (AppContainer $c): SecurityTokenRepository => new SecurityTokenRepository(
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
$container->set(SecurityOAuthTokenService::class, static fn (AppContainer $c): SecurityOAuthTokenService => new SecurityOAuthTokenService(
$c->get(SecurityBcSettingsGateway::class),
$c->get(SecurityTokenRepository::class)
));
$container->set(SecurityBcGateway::class, static fn (AppContainer $c): SecurityBcGateway => new SecurityBcGateway(
$c->get(SecurityBcSettingsGateway::class),
$c->get(SecurityOAuthTokenService::class),
$c->get(SessionStoreInterface::class)
));
// --- Check templates (per-product checklist catalog) ---
$container->set(SecurityCheckTemplateRepository::class, static fn (): SecurityCheckTemplateRepository => new SecurityCheckTemplateRepository());
$container->set(SecurityCheckTemplateService::class, static fn (AppContainer $c): SecurityCheckTemplateService => new SecurityCheckTemplateService(
$c->get(SecurityCheckTemplateRepository::class)
));
$container->set(SecurityMarkdownGateway::class, static fn (): SecurityMarkdownGateway => new SecurityMarkdownGateway());
// --- Security checks (instances) ---
$container->set(SecurityCheckProcess::class, static fn (): SecurityCheckProcess => new SecurityCheckProcess());
$container->set(SecurityCheckRepository::class, static fn (): SecurityCheckRepository => new SecurityCheckRepository());
$container->set(SecurityCheckService::class, static fn (AppContainer $c): SecurityCheckService => new SecurityCheckService(
$c->get(SecurityCheckRepository::class),
$c->get(SecurityCheckTemplateService::class),
$c->get(SecurityCheckProcess::class)
));
$container->set(SecurityReportPdfService::class, static fn (AppContainer $c): SecurityReportPdfService => new SecurityReportPdfService(
$c->get(SecurityCheckProcess::class),
$c->get(BrandingLogoService::class),
$c->get(TenantLogoService::class)
));
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Http\SessionStoreInterface;
/**
* Thin Business Central OData V4 gateway for the Security module.
*
* Standalone and intentionally minimal: only the two queries the security-check
* wizard needs (customer search + domains-for-customer) plus a connection test.
* Supports Basic and OAuth2 auth modes. No remote assets, TLS verification on
* (GR-SEC-004).
*
* @api Consumed by the customer-search / customer-domains / settings-test pages.
*/
class SecurityBcGateway
{
public const ENTITY_CUSTOMER = 'Integration_Customer_Card';
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly SecurityBcSettingsGateway $settingsGateway,
private readonly SecurityOAuthTokenService $oauthTokenService,
private readonly SessionStoreInterface $sessionStore
) {
}
/**
* Search customers by name or number.
*
* Tries contains() first, falls back to startswith() / range filter for BC
* versions that reject contains().
*
* @return array<int, array<string, mixed>>
*/
public function searchCustomers(string $query): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$escaped = $this->escapeODataStrictUserInput($query);
$upperQuery = mb_strtoupper($escaped);
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail';
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
$strategies = [
"contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')",
"startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')",
"Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'",
];
$lastException = null;
foreach ($strategies as $filter) {
$url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select;
try {
$response = $this->request('GET', $url);
if ($response !== null) {
return $this->extractODataValues($response);
}
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
} catch (\RuntimeException $e) {
$lastException = $e;
continue;
}
}
throw $lastException ?? new \RuntimeException('All OData search strategies failed');
}
/**
* Get active domains for a customer.
*
* @return array<int, array<string, mixed>>
*/
public function listDomainsForCustomer(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return [];
}
$escaped = $this->escapeODataStrictUserInput($customerNo);
$filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'";
$select = 'No,Customer_No,URL,State';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
. '?$filter=' . rawurlencode($filter)
. '&$top=200'
. '&$select=' . rawurlencode($select)
. '&$orderby=' . rawurlencode('URL asc');
$response = $this->request('GET', $url);
if ($response === null) {
return [];
}
return $this->extractODataValues($response);
}
/**
* Test the configured BC connection.
*
* @return array{ok: bool, error?: string, url?: string, http_code?: int}
*/
public function testConnection(): array
{
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'Configuration incomplete'];
}
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) . '?$top=1&$select=No';
$ch = curl_init();
if ($ch === false) {
return ['ok' => false, 'error' => 'curl_init failed'];
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$this->applyAuth($ch);
$responseBody = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($curlErrno !== 0) {
return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url];
}
if ($httpCode < 200 || $httpCode >= 300) {
$hint = match ($httpCode) {
401 => 'Unauthorized — check credentials',
403 => 'Forbidden — user has no access to this endpoint',
404 => 'Not found — check OData URL and company name',
default => 'HTTP ' . $httpCode,
};
return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode];
}
if (!is_string($responseBody)) {
return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode];
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode];
}
return ['ok' => true, 'url' => $url, 'http_code' => $httpCode];
}
/**
* @return array<string, mixed>|null
*/
private function request(string $method, string $url): ?array
{
if (!$this->settingsGateway->isConfigured()) {
return null;
}
$ch = curl_init();
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$this->applyAuth($ch);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
if ($httpCode >= 400 && $httpCode < 500) {
throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode);
}
return null;
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
/**
* @param \CurlHandle $ch
*/
private function applyAuth($ch): void
{
$authMode = $this->settingsGateway->getAuthMode();
if ($authMode === SecurityBcSettingsGateway::AUTH_MODE_BASIC) {
$user = $this->settingsGateway->getBasicUser() ?? '';
$password = $this->settingsGateway->getBasicPassword() ?? '';
curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
return;
}
if ($authMode !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) {
return;
}
$tenantId = $this->resolveCurrentTenantId();
if ($tenantId <= 0) {
return;
}
$token = $this->oauthTokenService->getAccessToken($tenantId, $this->buildOAuthAudience());
if ($token === null || trim($token) === '') {
return;
}
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Accept: application/json',
'OData-Version: 4.0',
'Authorization: Bearer ' . $token,
]);
}
/**
* @param array<string, mixed> $response
* @return array<int, array<string, mixed>>
*/
private function extractODataValues(array $response): array
{
$values = $response['value'] ?? [];
if (!is_array($values)) {
return [];
}
return array_values($values);
}
/**
* @throws \InvalidArgumentException If the value contains disallowed characters.
*/
private function escapeODataStrictUserInput(string $value): string
{
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
throw new \InvalidArgumentException('Search query contains invalid characters.');
}
return str_replace("'", "''", $value);
}
private function resolveCurrentTenantId(): int
{
try {
$session = $this->sessionStore->all();
} catch (\Throwable) {
return 0;
}
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
return $tenantId > 0 ? $tenantId : 0;
}
private function buildOAuthAudience(): ?string
{
$baseUrl = trim($this->settingsGateway->getODataBaseUrl());
if ($baseUrl === '') {
return null;
}
$parsed = parse_url($baseUrl);
if (!is_array($parsed) || empty($parsed['host'])) {
return null;
}
$scheme = strtolower((string) ($parsed['scheme'] ?? 'https'));
$host = (string) $parsed['host'];
$port = isset($parsed['port']) ? ':' . (int) $parsed['port'] : '';
return $scheme . '://' . $host . $port;
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
/**
* Global Business Central connection settings for the Security module.
*
* Standalone, intentionally thin: a single global connection stored in the core
* `settings` table (GR-CORE-011). Secrets are encrypted via SettingsCryptoGateway
* (GR-SEC-005). Setting keys are prefixed with 'security.' to avoid collisions
* with the helpdesk module's own BC settings.
*
* Per-tenant connection overrides are deliberately out of scope for v1.
*
* @api Consumed by the Security settings page and SecurityBcGateway.
*/
class SecurityBcSettingsGateway
{
public const KEY_AUTH_MODE = 'security.bc_auth_mode';
public const KEY_ODATA_BASE_URL = 'security.bc_odata_base_url';
public const KEY_COMPANY_NAME = 'security.bc_company_name';
public const KEY_BASIC_USER = 'security.bc_basic_user';
public const KEY_BASIC_PASSWORD_ENC = 'security.bc_basic_password_enc';
public const KEY_OAUTH_TENANT_ID = 'security.bc_oauth_tenant_id';
public const KEY_OAUTH_CLIENT_ID = 'security.bc_oauth_client_id';
public const KEY_OAUTH_CLIENT_SECRET_ENC = 'security.bc_oauth_client_secret_enc';
public const KEY_OAUTH_TOKEN_ENDPOINT = 'security.bc_oauth_token_endpoint';
public const AUTH_MODE_BASIC = 'basic';
public const AUTH_MODE_OAUTH2 = 'oauth2';
public function __construct(
private readonly SettingsMetadataGateway $settingsMetadataGateway,
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
) {
}
public function getAuthMode(): string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_AUTH_MODE) ?? ''));
return $value === self::AUTH_MODE_OAUTH2 ? self::AUTH_MODE_OAUTH2 : self::AUTH_MODE_BASIC;
}
public function setAuthMode(string $mode): bool
{
$mode = trim($mode);
if (!in_array($mode, [self::AUTH_MODE_BASIC, self::AUTH_MODE_OAUTH2], true)) {
return false;
}
return $this->settingsMetadataGateway->set(self::KEY_AUTH_MODE, $mode, self::KEY_AUTH_MODE);
}
public function getODataBaseUrl(): string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_ODATA_BASE_URL) ?? ''));
return $value !== '' ? rtrim($value, '/') : '';
}
public function setODataBaseUrl(?string $url): bool
{
$url = trim((string) ($url ?? ''));
if ($url !== '' && !preg_match('#^https://#i', $url)) {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_ODATA_BASE_URL,
$url !== '' ? rtrim($url, '/') : null,
self::KEY_ODATA_BASE_URL
);
}
public function getCompanyName(): string
{
return trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_COMPANY_NAME) ?? ''));
}
public function setCompanyName(?string $name): bool
{
$name = trim((string) ($name ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_COMPANY_NAME,
$name !== '' ? $name : null,
self::KEY_COMPANY_NAME
);
}
public function getBasicUser(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_BASIC_USER) ?? ''));
return $value !== '' ? $value : null;
}
public function setBasicUser(?string $user): bool
{
$user = trim((string) ($user ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_BASIC_USER,
$user !== '' ? $user : null,
self::KEY_BASIC_USER
);
}
public function getBasicPassword(): ?string
{
return $this->decryptSetting(self::KEY_BASIC_PASSWORD_ENC);
}
public function setBasicPassword(?string $password): bool
{
return $this->encryptSetting(self::KEY_BASIC_PASSWORD_ENC, $password);
}
public function getOAuthTenantId(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TENANT_ID) ?? ''));
return $value !== '' ? $value : null;
}
public function setOAuthTenantId(?string $tenantId): bool
{
$tenantId = trim((string) ($tenantId ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_OAUTH_TENANT_ID,
$tenantId !== '' ? $tenantId : null,
self::KEY_OAUTH_TENANT_ID
);
}
public function getOAuthClientId(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_ID) ?? ''));
return $value !== '' ? $value : null;
}
public function setOAuthClientId(?string $clientId): bool
{
$clientId = trim((string) ($clientId ?? ''));
return $this->settingsMetadataGateway->set(
self::KEY_OAUTH_CLIENT_ID,
$clientId !== '' ? $clientId : null,
self::KEY_OAUTH_CLIENT_ID
);
}
public function getOAuthClientSecret(): ?string
{
return $this->decryptSetting(self::KEY_OAUTH_CLIENT_SECRET_ENC);
}
public function setOAuthClientSecret(?string $secret): bool
{
return $this->encryptSetting(self::KEY_OAUTH_CLIENT_SECRET_ENC, $secret);
}
public function getOAuthTokenEndpoint(): ?string
{
$value = trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TOKEN_ENDPOINT) ?? ''));
return $value !== '' ? $value : null;
}
public function setOAuthTokenEndpoint(?string $endpoint): bool
{
$endpoint = trim((string) ($endpoint ?? ''));
if ($endpoint !== '' && !preg_match('#^https://#i', $endpoint)) {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_OAUTH_TOKEN_ENDPOINT,
$endpoint !== '' ? $endpoint : null,
self::KEY_OAUTH_TOKEN_ENDPOINT
);
}
/**
* Build the full OData entity URL for a given entity set.
*/
public function buildEntityUrl(string $entitySet): string
{
$baseUrl = $this->getODataBaseUrl();
$company = rawurlencode($this->getCompanyName());
return $baseUrl . "/Company('" . $company . "')/" . $entitySet;
}
/**
* Validate that required settings for the current auth mode are present.
*
* @return array<int, string> List of missing setting descriptions (empty = valid)
*/
public function validateConfiguration(): array
{
$missing = [];
if ($this->getODataBaseUrl() === '') {
$missing[] = 'BC OData Base URL';
}
if ($this->getCompanyName() === '') {
$missing[] = 'BC Company Name';
}
if ($this->getAuthMode() === self::AUTH_MODE_BASIC) {
if ($this->getBasicUser() === null) {
$missing[] = 'BC Basic Auth User';
}
if ($this->getBasicPassword() === null) {
$missing[] = 'BC Basic Auth Password';
}
} else {
if ($this->getOAuthClientId() === null) {
$missing[] = 'BC OAuth2 Client ID';
}
if ($this->getOAuthClientSecret() === null) {
$missing[] = 'BC OAuth2 Client Secret';
}
if ($this->getOAuthTokenEndpoint() === null) {
$missing[] = 'BC OAuth2 Token Endpoint';
}
}
return $missing;
}
public function isConfigured(): bool
{
return $this->validateConfiguration() === [];
}
private function decryptSetting(string $key): ?string
{
$encrypted = trim((string) ($this->settingsMetadataGateway->getValue($key) ?? ''));
if ($encrypted === '') {
return null;
}
try {
$decrypted = trim($this->settingsCryptoGateway->decryptString($encrypted));
} catch (\Throwable) {
return null;
}
return $decrypted !== '' ? $decrypted : null;
}
private function encryptSetting(string $key, ?string $value): bool
{
$value = trim((string) ($value ?? ''));
if ($value === '') {
return $this->settingsMetadataGateway->set($key, null, $key);
}
try {
$encrypted = $this->settingsCryptoGateway->encryptString($value);
} catch (\Throwable) {
return false;
}
return $this->settingsMetadataGateway->set($key, $encrypted, $key);
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace MintyPHP\Module\Security\Service;
/**
* The fixed internal security-check process.
*
* Identical for every check, so it lives in code (not the DB). Step labels and
* descriptions are translation keys — call t() at render time. The tech-checklist
* step (perform_check) expands into the per-product items from the check's
* tech_schema_snapshot.
*
* Pure logic only (no DB, no HTTP) — fully unit-testable.
*
* @api Consumed by Security pages, views and SecurityCheckService.
*/
final class SecurityCheckProcess
{
public const STATUS_OPEN = 'open';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUS_ARCHIVED = 'archived';
public const STEP_INFORMATION = 'information_gathering';
public const STEP_PERFORM_CHECK = 'perform_check';
public const STEP_REPORT = 'report';
public const KIND_STEP = 'step';
public const KIND_INFO = 'info';
public const KIND_TECH_CHECKLIST = 'tech_checklist';
public const FIELD_TEXTAREA = 'textarea';
public const FIELD_DATETIME = 'datetime';
/**
* Ordered fixed process steps.
*
* @return list<array{key: string, label: string, description: string, kind: string, fields?: list<array{key: string, label: string, type: string, required: bool}>}>
*/
public function steps(): array
{
return [
[
'key' => 'beauftragung',
'label' => 'Order received',
'description' => 'The security check was sold for one or more customer projects; sales briefs the security officer with all details.',
'kind' => self::KIND_STEP,
],
[
'key' => self::STEP_INFORMATION,
'label' => 'Gather information',
'description' => 'Collect all relevant information from Customer Care / the project lead.',
'kind' => self::KIND_INFO,
'fields' => [
['key' => 'technical_contact', 'label' => 'Technical contact at customer', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'deployment', 'label' => 'Deployment location & type', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'system_info', 'label' => 'Technical system information', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'external_systems', 'label' => 'External systems / API endpoints (exact URLs)', 'type' => self::FIELD_TEXTAREA, 'required' => true],
['key' => 'special_notes', 'label' => 'Special notes', 'type' => self::FIELD_TEXTAREA, 'required' => false],
],
],
[
'key' => 'scheduling',
'label' => 'Scheduling',
'description' => 'Agree internally and with the customer on the check date. Only the 23h go-live window is relevant to the customer.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => 'golive_start', 'label' => 'Go-live window start', 'type' => self::FIELD_DATETIME, 'required' => true],
['key' => 'golive_end', 'label' => 'Go-live window end', 'type' => self::FIELD_DATETIME, 'required' => true],
],
],
[
'key' => 'blocker_appointment',
'label' => 'Internal blocker appointment',
'description' => 'Create a 12 day internal blocker; invite Customer Care, Customizing and Sales.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => 'blocker_start', 'label' => 'Blocker start', 'type' => self::FIELD_DATETIME, 'required' => true],
['key' => 'blocker_end', 'label' => 'Blocker end', 'type' => self::FIELD_DATETIME, 'required' => true],
],
],
[
'key' => self::STEP_PERFORM_CHECK,
'label' => 'Perform security check',
'description' => 'Work through the product-specific technical checklist.',
'kind' => self::KIND_TECH_CHECKLIST,
],
[
'key' => 'report',
'label' => 'Create report & notify customer',
'description' => 'Create the report for the customer and notify them.',
'kind' => self::KIND_STEP,
],
];
}
/**
* Keys of steps completed manually (everything except the tech-checklist step).
*
* @return list<string>
*/
public function manualStepKeys(): array
{
$keys = [];
foreach ($this->steps() as $step) {
if ($step['kind'] !== self::KIND_TECH_CHECKLIST) {
$keys[] = $step['key'];
}
}
return $keys;
}
/**
* Structured fields captured on a given step (info textareas, datetimes, …).
*
* @return list<array{key: string, label: string, type: string, required: bool}>
*/
public function stepFields(string $stepKey): array
{
foreach ($this->steps() as $step) {
if ($step['key'] === $stepKey) {
return $step['fields'] ?? [];
}
}
return [];
}
/**
* Keys of a step's fields that must be filled before it can be completed.
* (E.g. "Special notes" and the free-text note stay optional.)
*
* @return list<string>
*/
public function requiredFieldKeys(string $stepKey): array
{
$keys = [];
foreach ($this->stepFields($stepKey) as $field) {
if ($field['required']) {
$keys[] = $field['key'];
}
}
return $keys;
}
/**
* Extract the checkable items from a tech-schema snapshot.
*
* @param array<string, mixed> $techSnapshot
* @return list<array{key: string, label: string}>
*/
public static function techCheckItems(array $techSnapshot): array
{
$items = is_array($techSnapshot['items'] ?? null) ? $techSnapshot['items'] : [];
$checks = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
if (($item['type'] ?? '') === 'check' && trim((string) ($item['key'] ?? '')) !== '') {
$checks[] = ['key' => (string) $item['key'], 'label' => (string) ($item['label'] ?? '')];
}
}
return $checks;
}
/**
* Compute progress + derived status purely from state.
*
* @param array<string, mixed> $processState per-step state keyed by step key
* @param array<string, mixed> $techSnapshot frozen tech-schema snapshot
* @param array<string, mixed> $techState per-item state keyed by item key
* @return array{done: int, total: int, percent: int, manual_done: int, manual_total: int, tech_done: int, tech_total: int, step6_done: bool, status: string}
*/
public function computeProgress(array $processState, array $techSnapshot, array $techState, bool $archived = false): array
{
$manualKeys = $this->manualStepKeys();
$manualTotal = count($manualKeys);
$manualDone = 0;
foreach ($manualKeys as $key) {
if (!empty($processState[$key]['done'])) {
$manualDone++;
}
}
$techItems = self::techCheckItems($techSnapshot);
$techTotal = count($techItems);
$techDone = 0;
foreach ($techItems as $item) {
if (!empty($techState[$item['key']]['done'])) {
$techDone++;
}
}
$step6Done = $techTotal === 0 ? true : ($techDone === $techTotal);
$total = $manualTotal + $techTotal;
$done = $manualDone + $techDone;
$percent = $total > 0 ? (int) floor(($done / $total) * 100) : 0;
if ($archived) {
$status = self::STATUS_ARCHIVED;
} elseif ($total > 0 && $done >= $total) {
$status = self::STATUS_COMPLETED;
} elseif ($done > 0) {
$status = self::STATUS_IN_PROGRESS;
} else {
$status = self::STATUS_OPEN;
}
return [
'done' => $done,
'total' => $total,
'percent' => $percent,
'manual_done' => $manualDone,
'manual_total' => $manualTotal,
'tech_done' => $techDone,
'tech_total' => $techTotal,
'step6_done' => $step6Done,
'status' => $status,
];
}
/**
* Whether every step preceding the final "report" step is complete: all
* manual steps except the report itself are done AND the technical checklist
* is fully ticked. Gates the "Generate PDF customer report" action so the
* report can only be produced once the check has actually been carried out.
*
* @param array<string, mixed> $processState
* @param array<string, mixed> $techSnapshot
* @param array<string, mixed> $techState
*/
public function reportPrerequisitesMet(array $processState, array $techSnapshot, array $techState): bool
{
foreach ($this->manualStepKeys() as $key) {
if ($key === self::STEP_REPORT) {
continue;
}
if (empty($processState[$key]['done'])) {
return false;
}
}
return $this->computeProgress($processState, $techSnapshot, $techState)['step6_done'];
}
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_OPEN => 'Open',
self::STATUS_IN_PROGRESS => 'In progress',
self::STATUS_COMPLETED => 'Completed',
self::STATUS_ARCHIVED => 'Archived',
default => $status,
};
}
public static function statusVariant(string $status): string
{
return match ($status) {
self::STATUS_IN_PROGRESS => 'warning',
self::STATUS_COMPLETED => 'success',
default => 'neutral',
};
}
}

View File

@@ -0,0 +1,296 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
/**
* Orchestrates security checks: creation from the wizard, checklist state updates,
* status derivation, archival and listing.
*
* The fixed process definition + progress/status math live in SecurityCheckProcess;
* the per-product technical checklist is frozen onto the check at creation time.
*
* @api Consumed by Security check pages (list, create wizard, workspace).
*/
class SecurityCheckService
{
public function __construct(
private readonly SecurityCheckRepository $repository,
private readonly SecurityCheckTemplateService $templateService,
private readonly SecurityCheckProcess $process
) {
}
/**
* Create a check from the wizard selection (customer + domain + product/template).
*
* @param array<string, mixed> $input
* @return array{ok: bool, id?: int, errors: array<string, string>}
*/
public function create(int $tenantId, array $input, int $actorUserId): array
{
$debitorNo = trim((string) ($input['debitor_no'] ?? ''));
$debitorName = trim((string) ($input['debitor_name'] ?? ''));
$domainNo = trim((string) ($input['domain_no'] ?? ''));
$domainUrl = trim((string) ($input['domain_url'] ?? ''));
$productCode = trim((string) ($input['product_code'] ?? ''));
$ownerUserId = (int) ($input['owner_user_id'] ?? $actorUserId);
$errors = [];
if ($tenantId <= 0) {
$errors['tenant'] = t('No tenant context');
}
if ($debitorNo === '') {
$errors['debitor_no'] = t('Please select a customer');
}
if ($domainNo === '') {
$errors['domain_no'] = t('Please select a domain');
}
$template = null;
if ($productCode === '') {
$errors['product_code'] = t('Please select a software product');
} else {
$template = $this->templateService->findByCode($tenantId, $productCode);
if ($template === null || (int) ($template['active'] ?? 0) !== 1) {
$errors['product_code'] = t('Selected check template not found');
}
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
/** @var array<string, mixed> $template */
$schema = $this->templateService->decodeSchema($template);
$id = $this->repository->insert($tenantId, [
'debitor_no' => $debitorNo,
'debitor_name' => $debitorName,
'domain_no' => $domainNo,
'domain_url' => $domainUrl,
'product_code' => $productCode,
'product_name' => (string) ($template['product_name'] ?? $productCode),
'template_id' => (int) ($template['id'] ?? 0),
'owner_user_id' => $ownerUserId > 0 ? $ownerUserId : null,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => json_encode((object) []),
'tech_schema_snapshot_json' => json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'tech_state_json' => json_encode((object) []),
'created_by' => $actorUserId,
]);
if ($id === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check could not be created')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
/**
* Find a check enriched with decoded state, the frozen tech checklist and progress.
*
* @return array<string, mixed>|null
*/
public function findById(int $tenantId, int $id): ?array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return null;
}
$row['process_state'] = $this->decode($row['process_state_json'] ?? '');
$row['tech_schema_snapshot'] = $this->decode($row['tech_schema_snapshot_json'] ?? '');
$row['tech_state'] = $this->decode($row['tech_state_json'] ?? '');
$row['tech_items'] = SecurityCheckProcess::techCheckItems($row['tech_schema_snapshot']);
$row['progress'] = $this->process->computeProgress(
$row['process_state'],
$row['tech_schema_snapshot'],
$row['tech_state'],
(string) ($row['status'] ?? '') === SecurityCheckProcess::STATUS_ARCHIVED
);
return $row;
}
/**
* Save the whole checklist workspace form (steps + info fields + tech items).
* Recomputes derived status. Stamps who/when on each item that newly turns done.
*
* @param array{step?: array<string, mixed>, info?: array<string, mixed>, tech?: array<string, mixed>} $input
* @return array{ok: bool, errors: array<string, string>, warning?: string}
*/
public function saveState(int $tenantId, int $id, array $input, int $actorUserId): array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check not found')]];
}
if ((string) ($row['status'] ?? '') === SecurityCheckProcess::STATUS_ARCHIVED) {
return ['ok' => false, 'errors' => ['general' => t('Archived checks cannot be edited')]];
}
$prevProcess = $this->decode($row['process_state_json'] ?? '');
$prevTech = $this->decode($row['tech_state_json'] ?? '');
$techSnapshot = $this->decode($row['tech_schema_snapshot_json'] ?? '');
$now = date('Y-m-d H:i:s');
$stepInput = is_array($input['step'] ?? null) ? $input['step'] : [];
$techInput = is_array($input['tech'] ?? null) ? $input['tech'] : [];
// Each step may carry structured fields (info textareas, datetimes, …).
// A step can only be completed once all of its required fields are filled in
// (e.g. "Special notes" and the free-text note stay optional).
$anyStepGated = false;
$newProcess = [];
foreach ($this->process->manualStepKeys() as $key) {
$fieldDefs = $this->process->stepFields($key);
$submittedFields = is_array($stepInput[$key]['fields'] ?? null) ? $stepInput[$key]['fields'] : [];
$fieldValues = [];
foreach ($fieldDefs as $field) {
$fieldValues[$field['key']] = trim((string) ($submittedFields[$field['key']] ?? ''));
}
$requirementsMet = true;
foreach ($this->process->requiredFieldKeys($key) as $requiredKey) {
if (($fieldValues[$requiredKey] ?? '') === '') {
$requirementsMet = false;
break;
}
}
$done = ($stepInput[$key]['done'] ?? '') !== '';
if ($done && !$requirementsMet) {
$done = false;
$anyStepGated = true;
}
$note = trim((string) ($stepInput[$key]['note'] ?? ''));
$entry = is_array($prevProcess[$key] ?? null) ? $prevProcess[$key] : [];
$entry = $this->applyCompletion($entry, $done, $actorUserId, $now);
$entry['note'] = $note;
if ($fieldDefs !== []) {
$entry['fields'] = $fieldValues;
}
$newProcess[$key] = $entry;
}
$newTech = [];
foreach (SecurityCheckProcess::techCheckItems($techSnapshot) as $item) {
$key = $item['key'];
$done = ($techInput[$key]['done'] ?? '') !== '';
$note = trim((string) ($techInput[$key]['note'] ?? ''));
$entry = is_array($prevTech[$key] ?? null) ? $prevTech[$key] : [];
$entry = $this->applyCompletion($entry, $done, $actorUserId, $now);
$entry['note'] = $note;
$newTech[$key] = $entry;
}
$progress = $this->process->computeProgress($newProcess, $techSnapshot, $newTech, false);
$ok = $this->repository->updateState($tenantId, $id, [
'process_state_json' => json_encode($newProcess, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'tech_state_json' => json_encode($newTech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'status' => $progress['status'],
'updated_by' => $actorUserId,
]);
if (!$ok) {
return ['ok' => false, 'errors' => ['general' => t('Could not save the security check')]];
}
if ($anyStepGated) {
return [
'ok' => true,
'errors' => [],
'warning' => t('Saved. Fill in all required fields to complete the affected steps.'),
];
}
return ['ok' => true, 'errors' => []];
}
/**
* @return array{ok: bool, errors: array<string, string>}
*/
public function setArchived(int $tenantId, int $id, bool $archived, int $actorUserId): array
{
$row = $this->repository->findById($tenantId, $id);
if ($row === null) {
return ['ok' => false, 'errors' => ['general' => t('Security check not found')]];
}
if ($archived) {
$status = SecurityCheckProcess::STATUS_ARCHIVED;
} else {
$progress = $this->process->computeProgress(
$this->decode($row['process_state_json'] ?? ''),
$this->decode($row['tech_schema_snapshot_json'] ?? ''),
$this->decode($row['tech_state_json'] ?? ''),
false
);
$status = $progress['status'];
}
$ok = $this->repository->updateStatus($tenantId, $id, $status, $actorUserId);
return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Could not update the status')]];
}
public function delete(int $tenantId, int $id): bool
{
return $this->repository->deleteById($tenantId, $id);
}
/**
* @api Called from checks-data endpoint
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
return $this->repository->listPaged($tenantId, $filters);
}
/**
* Tenant users that can be assigned as the owner of a check.
*
* @api Called from the create wizard
* @return list<array{id: int, display_name: string}>
*/
public function listAssignableUsers(int $tenantId): array
{
return $this->repository->listTenantUsers($tenantId);
}
/**
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
private function applyCompletion(array $entry, bool $done, int $actorUserId, string $now): array
{
$wasDone = !empty($entry['done']);
if ($done && !$wasDone) {
$entry['done'] = true;
$entry['by'] = $actorUserId;
$entry['at'] = $now;
} elseif (!$done) {
$entry['done'] = false;
unset($entry['by'], $entry['at']);
}
// done && wasDone → keep existing by/at (completion log preserved)
return $entry;
}
/**
* @return array<string, mixed>
*/
private function decode(mixed $json): array
{
$decoded = json_decode((string) $json, true);
return is_array($decoded) ? $decoded : [];
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
/**
* Business logic for security check templates (the per-product checklist catalog).
*
* A template owns the dynamic technical checklist for one software product. The
* checklist schema shape is:
* { "version": int, "items": [ {type:"section", label}, {type:"check", key, label, hint?, markdown?} ] }
* Item keys are auto-slugified from labels and kept unique within a template.
*
* @api Consumed by Security template pages and the create wizard.
*/
class SecurityCheckTemplateService
{
public const MAX_ITEMS = 100;
private const ALLOWED_ITEM_TYPES = ['section', 'check'];
public function __construct(
private readonly SecurityCheckTemplateRepository $repository
) {
}
/**
* @api Called from the create wizard product picker
* @return list<array<string, mixed>>
*/
public function listActive(int $tenantId): array
{
return $this->repository->listActive($tenantId);
}
/**
* @api Called from templates-data endpoint
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
return $this->repository->listPaged($tenantId, $filters);
}
public function findById(int $tenantId, int $id): ?array
{
return $this->repository->findById($tenantId, $id);
}
public function findByCode(int $tenantId, string $code): ?array
{
return $this->repository->findByCode($tenantId, $code);
}
/**
* Decode a template's stored tech schema into {version, items}.
*
* @param array<string, mixed> $template
* @return array{version: int, items: list<array<string, mixed>>}
*/
public function decodeSchema(array $template): array
{
$decoded = json_decode((string) ($template['tech_schema_json'] ?? ''), true);
if (!is_array($decoded)) {
return ['version' => (int) ($template['version'] ?? 1), 'items' => []];
}
$items = is_array($decoded['items'] ?? null) ? array_values($decoded['items']) : [];
return ['version' => (int) ($decoded['version'] ?? ($template['version'] ?? 1)), 'items' => $items];
}
/**
* @return array{ok: bool, id?: int, errors: array<string, string>}
*/
public function create(int $tenantId, string $code, string $name, int $actorUserId): array
{
$code = trim($code);
$name = trim($name);
$errors = [];
if ($tenantId <= 0) {
$errors['tenant'] = t('No tenant context');
}
if ($code === '') {
$errors['product_code'] = t('Product code cannot be empty');
} elseif (!preg_match('/^[A-Za-z0-9._-]+$/', $code)) {
$errors['product_code'] = t('Product code is invalid');
} elseif ($this->repository->codeExists($tenantId, $code)) {
$errors['product_code'] = t('A template with this product code already exists');
}
if ($name === '') {
$errors['product_name'] = t('Product name cannot be empty');
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$id = $this->repository->insert($tenantId, [
'product_code' => $code,
'product_name' => $name,
'tech_schema_json' => json_encode(['version' => 1, 'items' => []]),
'active' => 1,
'version' => 1,
'created_by' => $actorUserId,
]);
if ($id === null) {
return ['ok' => false, 'errors' => ['general' => t('Template could not be created')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
/**
* @return array{ok: bool, errors: array<string, string>}
*/
public function updateMeta(int $tenantId, int $id, string $name, bool $active, int $actorUserId): array
{
$name = trim($name);
if ($name === '') {
return ['ok' => false, 'errors' => ['product_name' => t('Product name cannot be empty')]];
}
if ($this->repository->findById($tenantId, $id) === null) {
return ['ok' => false, 'errors' => ['general' => t('Template not found')]];
}
$ok = $this->repository->updateMeta($tenantId, $id, $name, $active, $actorUserId);
return $ok ? ['ok' => true, 'errors' => []] : ['ok' => false, 'errors' => ['general' => t('Template could not be updated')]];
}
/**
* Validate + normalize the technical checklist and persist it (version bump).
*
* @param array<int, mixed> $items
* @return array{ok: bool, errors: array<string, string>, version?: int}
*/
public function saveTechSchema(int $tenantId, int $id, array $items, int $actorUserId): array
{
$template = $this->repository->findById($tenantId, $id);
if ($template === null) {
return ['ok' => false, 'errors' => ['general' => t('Template not found')]];
}
if (count($items) > self::MAX_ITEMS) {
return ['ok' => false, 'errors' => ['schema' => t('Maximum %d items allowed', self::MAX_ITEMS)]];
}
$normalized = [];
$usedKeys = [];
foreach ($items as $index => $item) {
if (!is_array($item)) {
continue;
}
$type = (string) ($item['type'] ?? 'check');
if (!in_array($type, self::ALLOWED_ITEM_TYPES, true)) {
return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid type', $index + 1)]];
}
$label = trim((string) ($item['label'] ?? ''));
if ($label === '') {
return ['ok' => false, 'errors' => ['schema' => t('Item %d is missing a label', $index + 1)]];
}
if ($type === 'section') {
$normalized[] = ['type' => 'section', 'label' => $label];
continue;
}
$key = trim((string) ($item['key'] ?? ''));
if ($key === '') {
$key = self::slugify($label);
}
$key = self::slugify($key);
if ($key === '') {
return ['ok' => false, 'errors' => ['schema' => t('Item %d has an invalid key', $index + 1)]];
}
if (isset($usedKeys[$key])) {
$suffix = 2;
while (isset($usedKeys[$key . '_' . $suffix])) {
$suffix++;
}
$key .= '_' . $suffix;
}
$usedKeys[$key] = true;
$entry = ['type' => 'check', 'key' => $key, 'label' => $label];
$hint = trim((string) ($item['hint'] ?? ''));
if ($hint !== '') {
$entry['hint'] = $hint;
}
$markdown = trim((string) ($item['markdown'] ?? ''));
if ($markdown !== '') {
$entry['markdown'] = $markdown;
}
$normalized[] = $entry;
}
$currentVersion = (int) ($template['version'] ?? 1);
$newVersion = $currentVersion + 1;
$schemaJson = json_encode(['version' => $newVersion, 'items' => $normalized], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($schemaJson)) {
return ['ok' => false, 'errors' => ['schema' => t('Could not encode the checklist')]];
}
$ok = $this->repository->updateSchema($tenantId, $id, $schemaJson, $newVersion, $actorUserId);
return $ok
? ['ok' => true, 'errors' => [], 'version' => $newVersion]
: ['ok' => false, 'errors' => ['general' => t('Checklist could not be saved')]];
}
private static function slugify(string $text): string
{
$text = trim(mb_strtolower($text));
$text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$text = preg_replace('/[^a-z0-9]+/', '_', $text) ?? '';
return trim($text, '_');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Module\Security\Service;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* Renders check-template Markdown guidance to sanitized HTML.
*
* Hardened config: raw HTML in the source is escaped (html_input=escape) and
* unsafe links (javascript:, data:, …) are dropped (allow_unsafe_links=false),
* so author-entered content can never inject markup/script into the workspace.
*
* @api Consumed by the security check workspace action to pre-render per-item guidance.
*/
final class SecurityMarkdownGateway
{
private ?GithubFlavoredMarkdownConverter $converter = null;
public function toHtml(string $markdown): string
{
$markdown = trim($markdown);
if ($markdown === '') {
return '';
}
$this->converter ??= new GithubFlavoredMarkdownConverter([
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
return (string) $this->converter->convert($markdown);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
/**
* OAuth2 client_credentials token service for the Security BC gateway.
*
* Tenant-scoped encrypted token cache. Supports v2 endpoints (scope) and legacy
* v1 endpoints (resource) with BC-oriented defaults.
*/
class SecurityOAuthTokenService
{
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly SecurityBcSettingsGateway $settingsGateway,
private readonly SecurityTokenRepository $tokenRepository
) {
}
public function getAccessToken(int $tenantId, ?string $resourceAudience = null): ?string
{
if ($this->settingsGateway->getAuthMode() !== SecurityBcSettingsGateway::AUTH_MODE_OAUTH2) {
return null;
}
if ($tenantId <= 0) {
return null;
}
$cached = $this->tokenRepository->findValidToken($tenantId);
if ($cached !== null) {
return $cached;
}
$clientId = trim((string) ($this->settingsGateway->getOAuthClientId() ?? ''));
$clientSecret = trim((string) ($this->settingsGateway->getOAuthClientSecret() ?? ''));
$tokenEndpoint = $this->resolveTokenEndpoint();
if ($clientId === '' || $clientSecret === '' || $tokenEndpoint === '') {
return null;
}
$tokenResponse = $this->requestToken($tokenEndpoint, $clientId, $clientSecret, $resourceAudience);
if (!is_array($tokenResponse)) {
return null;
}
$accessToken = trim((string) ($tokenResponse['access_token'] ?? ''));
if ($accessToken === '') {
return null;
}
$expiresIn = (int) ($tokenResponse['expires_in'] ?? 0);
if ($expiresIn <= 0) {
$expiresIn = (int) ($tokenResponse['ext_expires_in'] ?? 0);
}
if ($expiresIn <= 0) {
$expiresIn = 300;
}
$expiresAt = new \DateTimeImmutable('+' . max(60, $expiresIn - 60) . ' seconds');
$this->tokenRepository->storeToken($tenantId, $accessToken, $expiresAt);
return $accessToken;
}
private function resolveTokenEndpoint(): string
{
$endpoint = trim((string) ($this->settingsGateway->getOAuthTokenEndpoint() ?? ''));
if ($endpoint === '') {
return '';
}
$tenantId = trim((string) ($this->settingsGateway->getOAuthTenantId() ?? ''));
if ($tenantId !== '') {
$endpoint = str_replace(['{tenant}', '{tenant_id}'], $tenantId, $endpoint);
}
return $endpoint;
}
/**
* @return array<string,mixed>|null
*/
private function requestToken(string $endpoint, string $clientId, string $clientSecret, ?string $resourceAudience): ?array
{
foreach ($this->buildPayloadCandidates($endpoint, $clientId, $clientSecret, $resourceAudience) as $payload) {
$result = $this->requestTokenWithPayload($endpoint, $payload);
if ($result !== null) {
return $result;
}
}
return null;
}
/**
* @return array<int, array<string, string>>
*/
private function buildPayloadCandidates(string $endpoint, string $clientId, string $clientSecret, ?string $resourceAudience): array
{
$basePayload = [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => $clientSecret,
];
$payloads = [];
$isV2Endpoint = str_contains(strtolower($endpoint), '/oauth2/v2.0/token');
$audience = trim((string) ($resourceAudience ?? ''));
if ($isV2Endpoint) {
$scopes = ['https://api.businesscentral.dynamics.com/.default'];
if ($audience !== '') {
$scopes[] = rtrim($audience, '/') . '/.default';
}
foreach (array_values(array_unique($scopes)) as $scope) {
$payloads[] = $basePayload + ['scope' => $scope];
}
} else {
$resources = ['https://api.businesscentral.dynamics.com'];
if ($audience !== '') {
$resources[] = $audience;
}
foreach (array_values(array_unique($resources)) as $resource) {
$payloads[] = $basePayload + ['resource' => $resource];
}
}
$payloads[] = $basePayload;
return $payloads;
}
/**
* @param array<string,string> $payload
* @return array<string,mixed>|null
*/
private function requestTokenWithPayload(string $endpoint, array $payload): ?array
{
$ch = curl_init();
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload, '', '&', PHP_QUERY_RFC3986),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$responseBody = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
return null;
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
}

View File

@@ -0,0 +1,282 @@
<?php
namespace MintyPHP\Module\Security\Service;
use Dompdf\Dompdf;
use Dompdf\Options;
use MintyPHP\Service\Branding\BrandingLogoService;
use MintyPHP\Service\Tenant\TenantLogoService;
use Throwable;
/**
* Renders the customer-facing PDF report for a completed security check.
*
* Follows the core Dompdf pattern (see UserAccessPdfService): build a flat,
* render-ready context from the check, hydrate a self-contained PHTML template
* to an HTML string, then rasterise it with Dompdf (remote + PHP execution
* disabled). The context builder is pure (no I/O) so it is fully unit-testable;
* only the render step touches the filesystem / Dompdf.
*
* @api Consumed by the security/checks/report download action.
*/
final class SecurityReportPdfService
{
public function __construct(
private readonly SecurityCheckProcess $process,
private readonly BrandingLogoService $brandingLogoService,
private readonly ?TenantLogoService $tenantLogoService = null
) {
}
/**
* Render the customer report PDF. Returns raw PDF bytes, or '' if the
* renderer is unavailable, the template is missing, or rendering fails.
*
* @param array<string, mixed> $check Enriched check row (SecurityCheckService::findById)
* @param array{locale?: string, app_name?: string, tenant_uuid?: string} $options
*/
public function renderCustomerReportPdf(array $check, array $options = []): string
{
if (!class_exists(Dompdf::class) || !class_exists(Options::class)) {
return '';
}
$report = $this->buildReportContext($check);
$report['app_name'] = trim((string) ($options['app_name'] ?? ''));
$report['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? ''));
$report['generated_at'] = date('Y-m-d H:i');
$html = $this->renderTemplate($report);
if ($html === '') {
return '';
}
try {
$options = new Options();
// Keep rendering deterministic: no remote fetches, no embedded PHP.
$options->setIsRemoteEnabled(false);
$options->setIsPhpEnabled(false);
$options->setDefaultFont('DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4');
$dompdf->render();
$dompdf->addInfo('Title', (string) $report['title']);
$dompdf->addInfo('Subject', (string) $report['title']);
return (string) $dompdf->output();
} catch (Throwable) {
return '';
}
}
/**
* A filename-safe slug describing the report (customer + domain), e.g.
* "security-check-report-acme-acme-de". Always returns a non-empty base.
*
* @param array<string, mixed> $check
*/
public function buildReportFilename(array $check): string
{
$parts = array_filter([
(string) ($check['debitor_name'] ?? $check['debitor_no'] ?? ''),
(string) ($check['domain_url'] ?? $check['domain_no'] ?? ''),
]);
$slug = self::slugify(implode('-', $parts));
return 'security-check-report' . ($slug !== '' ? '-' . $slug : '') . '.pdf';
}
/**
* Build the flat, render-ready report context from an enriched check row.
* Pure transform (no filesystem / network) — translation via t() only.
*
* @param array<string, mixed> $check
* @return array<string, mixed>
*/
public function buildReportContext(array $check): array
{
$processState = is_array($check['process_state'] ?? null) ? $check['process_state'] : [];
$techState = is_array($check['tech_state'] ?? null) ? $check['tech_state'] : [];
$snapshot = is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [];
$progress = is_array($check['progress'] ?? null) ? $check['progress'] : [];
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
return [
'title' => t('Security check report'),
'customer_name' => (string) ($check['debitor_name'] ?? ''),
'customer_no' => (string) ($check['debitor_no'] ?? ''),
'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')),
'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')),
'status' => $status,
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
'summary' => [
'done' => (int) ($progress['done'] ?? 0),
'total' => (int) ($progress['total'] ?? 0),
'percent' => (int) ($progress['percent'] ?? 0),
'tech_done' => (int) ($progress['tech_done'] ?? 0),
'tech_total' => (int) ($progress['tech_total'] ?? 0),
],
'steps' => $this->buildSteps($processState),
'tech_sections' => $this->buildTechSections($snapshot, $techState),
];
}
/**
* High-level process milestones (label + completion date), excluding the
* internal field values and per-step notes that are not customer-facing.
*
* @param array<string, mixed> $processState
* @return list<array{number: int, label: string, done: bool, completed_at: string}>
*/
private function buildSteps(array $processState): array
{
$steps = [];
$number = 0;
foreach ($this->process->steps() as $step) {
$number++;
if ($step['kind'] === SecurityCheckProcess::KIND_TECH_CHECKLIST) {
continue;
}
$key = $step['key'];
$entry = is_array($processState[$key] ?? null) ? $processState[$key] : [];
$steps[] = [
'number' => $number,
'label' => t($step['label']),
'done' => !empty($entry['done']),
'completed_at' => trim((string) ($entry['at'] ?? '')),
];
}
return $steps;
}
/**
* The frozen technical checklist grouped by section, each item carrying its
* pass/fail state and any finding note. Items before the first section land
* in an unlabelled leading group.
*
* @param array<string, mixed> $snapshot
* @param array<string, mixed> $techState
* @return list<array{label: string, items: list<array{label: string, done: bool, note: string}>}>
*/
private function buildTechSections(array $snapshot, array $techState): array
{
$items = is_array($snapshot['items'] ?? null) ? $snapshot['items'] : [];
$sections = [];
$current = ['label' => '', 'items' => []];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
if (($item['type'] ?? '') === 'section') {
if ($current['items'] !== []) {
$sections[] = $current;
}
$current = ['label' => (string) ($item['label'] ?? ''), 'items' => []];
continue;
}
$key = trim((string) ($item['key'] ?? ''));
if ($key === '') {
continue;
}
$entry = is_array($techState[$key] ?? null) ? $techState[$key] : [];
$current['items'][] = [
'label' => (string) ($item['label'] ?? ''),
'done' => !empty($entry['done']),
'note' => trim((string) ($entry['note'] ?? '')),
];
}
if ($current['items'] !== []) {
$sections[] = $current;
}
return $sections;
}
/**
* @param array<string, mixed> $report
*/
private function renderTemplate(array $report): string
{
$templatePath = dirname(__DIR__, 4) . '/templates/pdf/customer-report.phtml';
if (!is_file($templatePath)) {
return '';
}
// Isolated scope: the template only sees $report, never this service's state.
$render = static function (string $__path, array $report): string {
ob_start();
include $__path;
return (string) ob_get_clean();
};
return $render($templatePath, $report);
}
/**
* Resolve a logo data URI: tenant light-logo → global branding logo →
* static brand asset → transparent pixel. Mirrors the core PDF pattern; the
* PDF has no theme context, so the light variant is always used.
*/
private function resolveLogoDataUri(string $tenantUuid): string
{
$path = '';
$mime = '';
if ($this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) {
$logoPath = $this->tenantLogoService->findLogoPath($tenantUuid, TenantLogoService::THEME_LIGHT, 128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->tenantLogoService->detectMime($logoPath);
}
}
if ($path === '' && $this->brandingLogoService->hasLogo()) {
$logoPath = $this->brandingLogoService->findLogoPath(128);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->brandingLogoService->detectMime($logoPath);
}
}
if ($path === '') {
$fallback = dirname(__DIR__, 6) . '/web/brand/logo.svg';
if (is_file($fallback)) {
$path = $fallback;
$mime = 'image/svg+xml';
}
}
if ($path === '' || !is_file($path)) {
return self::transparentPixelDataUri();
}
$content = @file_get_contents($path);
if ($content === false || $content === '') {
return self::transparentPixelDataUri();
}
return 'data:' . ($mime !== '' ? $mime : 'image/png') . ';base64,' . base64_encode($content);
}
private static function transparentPixelDataUri(): string
{
// Smallest safe placeholder so the <img> tag stays valid when no logo exists.
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==';
}
private static function slugify(string $value): string
{
$value = strtolower(trim($value));
if ($value === '') {
return '';
}
$value = (string) preg_replace('/[^a-z0-9]+/', '-', $value);
return trim($value, '-');
}
}

View File

@@ -0,0 +1,19 @@
-- Security module: per-product check templates (the curated product catalog).
-- Each template carries the dynamic technical checklist for one software product.
-- Tenant-scoped; BC is not involved (the template catalog IS the product list).
CREATE TABLE IF NOT EXISTS `security_check_templates` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`product_code` VARCHAR(60) NOT NULL,
`product_name` VARCHAR(255) NOT NULL DEFAULT '',
`tech_schema_json` TEXT NULL DEFAULT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`version` INT UNSIGNED NOT NULL DEFAULT 1,
`created_by` INT UNSIGNED NULL DEFAULT NULL,
`updated_by` INT UNSIGNED NULL DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sct_tenant_code` (`tenant_id`, `product_code`),
KEY `idx_sct_tenant_active` (`tenant_id`, `active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,29 @@
-- Security module: a security check instance (the "project").
-- Fixed 7-step process state + product-specific technical checklist state are
-- stored as JSON. tech_schema_snapshot_json freezes the template's checklist at
-- creation time so later template edits don't mutate in-flight checks.
CREATE TABLE IF NOT EXISTS `security_checks` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`debitor_no` VARCHAR(30) NOT NULL DEFAULT '',
`debitor_name` VARCHAR(255) NOT NULL DEFAULT '',
`domain_no` VARCHAR(30) NOT NULL DEFAULT '',
`domain_url` VARCHAR(255) NOT NULL DEFAULT '',
`product_code` VARCHAR(60) NOT NULL DEFAULT '',
`product_name` VARCHAR(255) NOT NULL DEFAULT '',
`template_id` BIGINT UNSIGNED NULL DEFAULT NULL,
`owner_user_id` INT UNSIGNED NULL DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'open',
`process_state_json` TEXT NULL DEFAULT NULL,
`tech_schema_snapshot_json` TEXT NULL DEFAULT NULL,
`tech_state_json` TEXT NULL DEFAULT NULL,
`created_by` INT UNSIGNED NOT NULL,
`updated_by` INT UNSIGNED NULL DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_sc_tenant` (`tenant_id`),
KEY `idx_sc_tenant_status` (`tenant_id`, `status`),
KEY `idx_sc_tenant_debitor` (`tenant_id`, `debitor_no`),
KEY `idx_sc_tenant_product` (`tenant_id`, `product_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,13 @@
-- Security module: OAuth2 token cache for the thin BC gateway
-- (used only when the BC connection runs in OAuth2 auth mode).
-- Tokens are stored encrypted and tenant-scoped.
CREATE TABLE IF NOT EXISTS `security_oauth_token_cache` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`access_token_encrypted` TEXT NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_security_oauth_tenant` (`tenant_id`),
INDEX `idx_security_oauth_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

162
modules/security/module.php Normal file
View File

@@ -0,0 +1,162 @@
<?php
/**
* Security module manifest.
*
* Standalone module (no cross-module dependency). Lets the security officer
* create and track customer security checks: a fixed internal process plus a
* per-product technical checklist. Customer + domain selection use a thin,
* self-contained Business Central OData gateway; the software-product catalog
* is owned by this module (the check templates ARE the product list).
*/
return [
'id' => 'security',
'version' => '1.0.0',
'enabled_by_default' => false,
'load_order' => 30,
'requires' => [],
'routes' => [
['path' => 'security/checks', 'target' => 'security/checks'],
['path' => 'security/checks-data', 'target' => 'security/checks-data'],
['path' => 'security/checks/create', 'target' => 'security/checks/create'],
['path' => 'security/checks/edit/{id}', 'target' => 'security/checks/edit'],
['path' => 'security/checks/report/{id}', 'target' => 'security/checks/report'],
['path' => 'security/customer-search-data', 'target' => 'security/customer-search-data'],
['path' => 'security/customer-domains-data', 'target' => 'security/customer-domains-data'],
['path' => 'security/templates', 'target' => 'security/templates'],
['path' => 'security/templates-data', 'target' => 'security/templates-data'],
['path' => 'security/templates/create', 'target' => 'security/templates/create'],
['path' => 'security/templates/edit/{id}', 'target' => 'security/templates/edit'],
['path' => 'security/settings', 'target' => 'security/settings'],
['path' => 'security/settings/test-connection-data', 'target' => 'security/settings/test-connection-data'],
],
'public_paths' => [],
'container_registrars' => [
\MintyPHP\Module\Security\SecurityContainerRegistrar::class,
],
'ui_slots' => [
'aside.tab_panel' => [
[
'key' => 'security',
'label' => 'Security',
'icon' => 'bi-shield-check',
'icon_fill' => 'bi-shield-check',
'href' => '',
'permission' => 'security.access',
'panel_template' => 'templates/aside-security-panel.phtml',
'details_storage' => 'aside-security-sections-v1',
'details_open_active' => true,
'order' => 850,
],
],
'runtime.component' => [
[
'key' => 'security-customer-domain-select',
'script' => 'modules/security/js/security-customer-domain-select.js',
'export' => 'initSecurityCustomerDomainSelect',
'selector' => '#security-domain-group[data-app-component="security-customer-domain-select"]',
'config_path' => 'components.security.customerDomainSelect',
'default_config' => [],
'phase' => 'default',
'permission' => 'security.checks.create',
'order' => 851,
],
[
'key' => 'security-check-workspace',
'script' => 'modules/security/js/security-check-workspace.js',
'export' => 'initSecurityCheckWorkspace',
'selector' => '[data-app-component="security-check-workspace"]',
'config_path' => 'components.security.checkWorkspace',
'default_config' => [],
'phase' => 'default',
'permission' => 'security.checks.view',
'order' => 852,
],
[
'key' => 'security-template-schema-editor',
'script' => 'modules/security/js/security-template-schema-editor.js',
'export' => 'initSecurityTemplateSchemaEditor',
'selector' => '#security-schema-editor[data-app-component="security-template-schema-editor"]',
'config_path' => 'components.security.templateSchemaEditor',
'default_config' => [],
'phase' => 'default',
'permission' => 'security.templates.manage',
'order' => 853,
],
[
'key' => 'security-settings',
'script' => 'modules/security/js/security-settings.js',
'export' => 'initSecuritySettings',
'selector' => '[data-app-component="security-settings"]',
'config_path' => 'components.security.settings',
'default_config' => [],
'phase' => 'default',
'permission' => 'security.settings.manage',
'order' => 854,
],
],
],
'authorization_policies' => [
\MintyPHP\Module\Security\SecurityAuthorizationPolicy::class,
],
'permissions' => [
[
'key' => 'security.access',
'description' => 'Access the Security module',
'active' => true,
'is_system' => true,
],
[
'key' => 'security.checks.view',
'description' => 'View security checks',
'active' => true,
'is_system' => true,
],
[
'key' => 'security.checks.create',
'description' => 'Create security checks and edit their checklist',
'active' => true,
'is_system' => true,
],
[
'key' => 'security.checks.manage',
'description' => 'Manage all security checks (archive, delete)',
'active' => true,
'is_system' => true,
],
[
'key' => 'security.templates.manage',
'description' => 'Manage security check templates and their technical checklists',
'active' => true,
'is_system' => true,
],
[
'key' => 'security.settings.manage',
'description' => 'Manage Security module Business Central connection settings',
'active' => true,
'is_system' => true,
],
],
'search_resources' => [],
'asset_groups' => [
'security' => [
'modules/security/css/security.css',
],
],
'scheduler_jobs' => [],
'layout_context_providers' => [
\MintyPHP\Module\Security\Providers\SecurityLayoutProvider::class,
],
'session_providers' => [],
'event_listeners' => [],
'deactivation_handler' => null,
'migrations_path' => 'migrations',
'i18n_path' => 'i18n',
];

View File

@@ -0,0 +1,47 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/checks/filter-schema.php');
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
if (!isset($filters['view']) || !in_array($filters['view'], ['active', 'done'], true)) {
$filters['view'] = 'active';
}
$result = app(SecurityCheckService::class)->listPaged($tenantId, $filters);
$rows = $result['rows'] ?? [];
$total = (int) ($result['total'] ?? 0);
$editBaseUrl = lurl('security/checks/edit/');
$preparedRows = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
$status = (string) ($row['status'] ?? 'open');
$preparedRows[] = [
'id' => $id,
'debitor_name' => (string) ($row['debitor_name'] ?? ''),
'domain_url' => (string) ($row['domain_url'] ?? ''),
'product_name' => (string) ($row['product_name'] ?? ($row['product_code'] ?? '')),
'status' => $status,
'status_label' => t(SecurityCheckProcess::statusLabel($status)),
'status_variant' => SecurityCheckProcess::statusVariant($status),
'owner_name' => (string) ($row['owner_name'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
'edit_url' => $id > 0 ? $editBaseUrl . $id : '',
];
}
gridJsonDataResult($preparedRows, $total);

View File

@@ -0,0 +1,78 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE);
$request = requestInput();
$returnTarget = requestResolveReturnTarget('security/checks');
$closeTarget = requestResolveReturnTarget('security/checks');
$createTarget = requestPathWithReturnTarget('security/checks/create', $returnTarget);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$templateService = app(SecurityCheckTemplateService::class);
$checkService = app(SecurityCheckService::class);
$errorBag = formErrors();
$form = [
'debitor_no' => (string) $request->body('debitor_no', ''),
'debitor_name' => (string) $request->body('debitor_name', ''),
'domain_no' => (string) $request->body('domain_no', ''),
'domain_url' => (string) $request->body('domain_url', ''),
'product_code' => (string) $request->body('product_code', ''),
'owner_user_id' => (int) $request->body('owner_user_id', (string) $userId),
];
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
Router::redirect($createTarget);
return;
}
$result = $checkService->create($tenantId, [
'debitor_no' => $form['debitor_no'],
'debitor_name' => $form['debitor_name'],
'domain_no' => $form['domain_no'],
'domain_url' => $form['domain_url'],
'product_code' => $form['product_code'],
'owner_user_id' => $form['owner_user_id'],
], $userId);
if ($result['ok'] ?? false) {
$editUrl = lurl('security/checks/edit/' . (int) $result['id']);
Flash::success(t('Security check created'), $editUrl, 'security_check_created');
Router::redirect($editUrl);
return;
}
$errorBag->merge($result['errors'] ?? []);
}
$templates = $templateService->listActive($tenantId);
$assignableUsers = $checkService->listAssignableUsers($tenantId);
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
Buffer::set('title', t('New security check'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Security checks'), 'path' => 'security/checks'],
['label' => t('New security check')],
];

View File

@@ -0,0 +1,132 @@
<?php
$form = is_array($form ?? null) ? $form : [];
$errors = is_array($errors ?? null) ? $errors : [];
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
$templates = is_array($templates ?? null) ? $templates : [];
$assignableUsers = is_array($assignableUsers ?? null) ? $assignableUsers : [];
$closeTarget = $closeTarget ?? 'security/checks';
?>
<div class="app-wizard-content">
<div class="app-wizard-header">
<a href="<?php e(lurl($closeTarget)); ?>" class="app-wizard-back" data-tooltip="<?php e(t('Back to list')); ?>">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="app-wizard-title"><?php e(t('New security check')); ?></h1>
</div>
<?php if ($validationSummaryErrors): ?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php endif; ?>
<div class="app-wizard-card">
<form method="post" id="security-check-create-form">
<h2 class="app-wizard-card-heading"><?php e(t('Select customer, domain and product')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('Choose the customer, domain and software product this security check applies to.')); ?></p>
<div class="app-wizard-field-group">
<label><?php e(t('Customer')); ?> <span class="security-form-required">*</span></label>
<div
data-app-lookup
data-lookup-url="<?php e(lurl('security/customer-search-data')); ?>"
data-lookup-min-chars="2"
data-lookup-debounce="300"
data-lookup-value-key="value"
data-lookup-display-key="label"
data-lookup-placeholder="<?php e(t('Search by name or number...')); ?>"
data-lookup-empty-text="<?php e(t('No customers found')); ?>"
data-lookup-error-text="<?php e(t('Search failed')); ?>"
data-lookup-initial-value="<?php e($form['debitor_no'] ?? ''); ?>"
data-lookup-initial-display="<?php e($form['debitor_name'] ?? ''); ?>"
>
<input type="hidden" name="debitor_no" data-lookup-value value="<?php e($form['debitor_no'] ?? ''); ?>">
<input type="hidden" name="debitor_name" data-lookup-display-value value="<?php e($form['debitor_name'] ?? ''); ?>">
</div>
<?php if (!empty($errors['debitor_no'])): ?>
<p class="field-error"><?php e($errors['debitor_no']); ?></p>
<?php endif; ?>
</div>
<div class="app-wizard-field-group" id="security-domain-group" data-app-component="security-customer-domain-select"
data-domains-url="<?php e(lurl('security/customer-domains-data')); ?>"
data-text-initial="<?php e(t('Select a customer first...')); ?>"
data-text-loading="<?php e(t('Loading domains...')); ?>"
data-text-select="<?php e(t('Please select...')); ?>"
data-text-empty="<?php e(t('No domains found for this customer')); ?>"
data-text-error="<?php e(t('Failed to load domains')); ?>"
data-initial-debitor="<?php e($form['debitor_no'] ?? ''); ?>"
data-initial-domain="<?php e($form['domain_no'] ?? ''); ?>"
>
<label for="security-domain"><?php e(t('Domain')); ?> <span class="security-form-required">*</span></label>
<div class="app-wizard-domain-select-wrap">
<select id="security-domain" disabled>
<option value=""><?php e(t('Select a customer first...')); ?></option>
</select>
</div>
<input type="hidden" name="domain_no" id="security-domain-no" value="<?php e($form['domain_no'] ?? ''); ?>">
<input type="hidden" name="domain_url" id="security-domain-url" value="<?php e($form['domain_url'] ?? ''); ?>">
<div id="security-domain-feedback" class="app-wizard-field-feedback" role="alert" hidden></div>
<div id="security-domain-manual" hidden>
<p class="app-wizard-field-hint"><?php e(t('No domains found for this customer. Enter domain name manually:')); ?></p>
<input type="text" id="security-domain-manual-no" placeholder="<?php e(t('Domain name (e.g. example.de)')); ?>"
value="<?php e($form['domain_no'] ?? ''); ?>" autocomplete="off">
<input type="text" id="security-domain-manual-url" placeholder="<?php e(t('URL (optional, e.g. https://example.de)')); ?>"
value="<?php e($form['domain_url'] ?? ''); ?>" autocomplete="off" style="margin-top:0.4rem;">
</div>
<?php if (!empty($errors['domain_no'])): ?>
<p class="field-error"><?php e($errors['domain_no']); ?></p>
<?php endif; ?>
</div>
<div class="app-wizard-field-group">
<label for="security-product-code"><?php e(t('Software product')); ?> <span class="security-form-required">*</span></label>
<?php if ($templates === []): ?>
<div class="notice" data-variant="info" role="alert">
<p><?php e(t('No check templates found. Create a check template first.')); ?></p>
</div>
<?php else: ?>
<select id="security-product-code" name="product_code">
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach ($templates as $tpl):
$code = (string) ($tpl['product_code'] ?? '');
$name = trim((string) ($tpl['product_name'] ?? ''));
$displayLabel = $name !== '' ? $name : $code;
?>
<option value="<?php e($code); ?>"<?php if (($form['product_code'] ?? '') === $code): ?> selected<?php endif; ?>>
<?php e($displayLabel); ?>
</option>
<?php endforeach; ?>
</select>
<?php if (!empty($errors['product_code'])): ?>
<p class="field-error"><?php e($errors['product_code']); ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="app-wizard-field-group">
<label for="security-owner"><?php e(t('Owner')); ?></label>
<select id="security-owner" name="owner_user_id">
<?php
$selectedOwner = (int) ($form['owner_user_id'] ?? 0);
foreach ($assignableUsers as $assignableUser):
$assignableUserId = (int) ($assignableUser['id'] ?? 0);
if ($assignableUserId <= 0) {
continue;
}
$assignableUserName = trim((string) ($assignableUser['display_name'] ?? ''));
?>
<option value="<?php e((string) $assignableUserId); ?>"<?php if ($assignableUserId === $selectedOwner): ?> selected<?php endif; ?>><?php e($assignableUserName !== '' ? $assignableUserName : ('#' . $assignableUserId)); ?></option>
<?php endforeach; ?>
</select>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"<?php if ($templates === []): ?> disabled<?php endif; ?>><?php e(t('Create security check')); ?></button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,166 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
use MintyPHP\Router;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
$request = requestInput();
$returnTarget = requestResolveReturnTarget();
$closeTarget = requestResolveReturnTarget('security/checks');
$checkId = (int) ($id ?? 0);
$editBasePath = 'security/checks/edit/' . $checkId;
$editTarget = requestPathWithReturnTarget($editBasePath, $returnTarget);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(SecurityCheckService::class);
$process = app(SecurityCheckProcess::class);
$check = $service->findById($tenantId, $checkId);
if ($check === null) {
Flash::error(t('Security check not found'), $closeTarget, 'security_check_not_found');
Router::redirect($closeTarget);
return;
}
$authService = app(AuthorizationService::class);
$actorContext = ['actor_user_id' => $userId];
$canManage = $authService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_MANAGE, $actorContext)->isAllowed();
$canCreate = $authService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE, $actorContext)->isAllowed();
$canEdit = ($canManage || $canCreate) && (string) ($check['status'] ?? '') !== SecurityCheckProcess::STATUS_ARCHIVED;
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
Router::redirect($editTarget);
return;
}
$action = (string) $request->body('action', 'save');
if ($action === 'delete') {
if (!$canManage) {
Flash::error(t('Permission denied'), $editTarget, 'permission_denied');
Router::redirect($editTarget);
return;
}
$service->delete($tenantId, $checkId);
Flash::success(t('Security check deleted'), null, 'security_check_deleted');
Router::redirect($closeTarget);
return;
}
if ($action === 'archive' || $action === 'unarchive') {
if (!$canManage) {
Flash::error(t('Permission denied'), $editTarget, 'permission_denied');
Router::redirect($editTarget);
return;
}
$service->setArchived($tenantId, $checkId, $action === 'archive', $userId);
Flash::success($action === 'archive' ? t('Security check archived') : t('Security check reopened'), $editTarget, 'security_check_status');
Router::redirect($editTarget);
return;
}
// Normal checklist save
if (!$canEdit) {
Flash::error(t('This security check cannot be edited'), $editTarget, 'not_editable');
Router::redirect($editTarget);
return;
}
$result = $service->saveState($tenantId, $checkId, [
'step' => (array) $request->body('step', []),
'tech' => (array) $request->body('tech', []),
], $userId);
if ($result['ok'] ?? false) {
$warning = (string) ($result['warning'] ?? '');
if ($warning !== '') {
Flash::add('warning', $warning, $editTarget, 'security_check_saved');
} else {
Flash::success(t('Security check saved'), $editTarget, 'security_check_saved');
}
Router::redirect($editTarget);
return;
}
$firstError = reset($result['errors']) ?: t('Could not save the security check');
Flash::error($firstError, $editTarget, 'security_check_save_failed');
Router::redirect($editTarget);
return;
}
// Resolve user-id → display-name for the completion log ("who completed it").
$userNames = [];
foreach ($service->listAssignableUsers($tenantId) as $assignableUser) {
$userNames[(int) $assignableUser['id']] = (string) $assignableUser['display_name'];
}
$steps = $process->steps();
$techItems = $check['tech_items'] ?? [];
// Pre-render each checkpoint's Markdown guidance to sanitized HTML for the workspace.
$markdownGateway = app(SecurityMarkdownGateway::class);
if (is_array($check['tech_schema_snapshot']['items'] ?? null)) {
$check['tech_schema_snapshot']['items'] = array_map(
static function ($item) use ($markdownGateway) {
if (
is_array($item)
&& ($item['type'] ?? '') === 'check'
&& trim((string) ($item['markdown'] ?? '')) !== ''
) {
$item['markdown_html'] = $markdownGateway->toHtml((string) $item['markdown']);
}
return $item;
},
$check['tech_schema_snapshot']['items']
);
}
$processState = $check['process_state'] ?? [];
$techState = $check['tech_state'] ?? [];
$progress = $check['progress'] ?? [];
$status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN);
// Step 6 ("report") can produce a customer PDF only once every preceding step
// is complete. Authoritative gate; the view mirrors it and the report action
// re-checks it server-side.
$canGenerateReport = $process->reportPrerequisitesMet(
is_array($processState) ? $processState : [],
is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [],
is_array($techState) ? $techState : []
);
$reportPdfUrl = lurl('security/checks/report/' . $checkId);
$titleText = t('Security check') . ' — ' . (string) ($check['debitor_name'] ?? ($check['debitor_no'] ?? ''));
Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Security checks'), 'path' => 'security/checks'],
['label' => $titleText],
];

View File

@@ -0,0 +1,257 @@
<?php
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
$check = is_array($check ?? null) ? $check : [];
$steps = is_array($steps ?? null) ? $steps : [];
$techItems = is_array($techItems ?? null) ? $techItems : [];
$processState = is_array($processState ?? null) ? $processState : [];
$techState = is_array($techState ?? null) ? $techState : [];
$progress = is_array($progress ?? null) ? $progress : [];
$userNames = is_array($userNames ?? null) ? $userNames : [];
$canEdit = $canEdit ?? false;
$canManage = $canManage ?? false;
$canGenerateReport = $canGenerateReport ?? false;
$reportPdfUrl = (string) ($reportPdfUrl ?? '');
$status = (string) ($status ?? SecurityCheckProcess::STATUS_OPEN);
$readonly = !$canEdit;
$percent = (int) ($progress['percent'] ?? 0);
$doneCount = (int) ($progress['done'] ?? 0);
$totalCount = (int) ($progress['total'] ?? 0);
$completionMeta = static function (array $entry) use ($userNames): string {
if (empty($entry['done'])) {
return '';
}
$parts = [];
$by = (int) ($entry['by'] ?? 0);
if ($by > 0 && isset($userNames[$by]) && $userNames[$by] !== '') {
$parts[] = $userNames[$by];
}
$at = trim((string) ($entry['at'] ?? ''));
if ($at !== '') {
$parts[] = $at;
}
return $parts === [] ? '' : implode(' · ', $parts);
};
?>
<div class="app-details-container">
<section data-tab-panel data-tab-panel-wide>
<?php
$titlebar = [
'title' => t('Security check'),
'backHref' => lurl($closeTarget ?? 'security/checks'),
];
if (!$readonly) {
$titlebar['actions'] = [
[
'label' => t('Save'),
'type' => 'submit',
'form' => 'security-check-form',
'name' => 'action',
'value' => 'save',
'class' => 'primary',
'tone' => 'success',
],
];
}
require templatePath('partials/app-details-titlebar.phtml');
?>
<!-- Summary -->
<div class="security-check-summary">
<dl class="security-check-meta">
<div><dt><?php e(t('Customer')); ?></dt><dd><?php e((string) ($check['debitor_name'] ?? '')); ?> <span class="app-muted">(<?php e((string) ($check['debitor_no'] ?? '')); ?>)</span></dd></div>
<div><dt><?php e(t('Domain')); ?></dt><dd><?php e((string) ($check['domain_url'] ?? ($check['domain_no'] ?? ''))); ?></dd></div>
<div><dt><?php e(t('Software product')); ?></dt><dd><?php e((string) ($check['product_name'] ?? ($check['product_code'] ?? ''))); ?></dd></div>
<div><dt><?php e(t('Owner')); ?></dt><dd><?php e((string) ($check['owner_name'] ?? '—')); ?></dd></div>
<div><dt><?php e(t('Status')); ?></dt><dd><span class="badge" data-variant="<?php e(SecurityCheckProcess::statusVariant($status)); ?>"><?php e(t(SecurityCheckProcess::statusLabel($status))); ?></span></dd></div>
</dl>
</div>
<div class="security-check-progress">
<div class="security-check-progress-bar" role="progressbar" aria-label="<?php e(t('Progress')); ?>" aria-valuenow="<?php e((string) $percent); ?>" aria-valuemin="0" aria-valuemax="100">
<div class="security-check-progress-fill" style="width: <?php e((string) $percent); ?>%"></div>
</div>
<span class="security-check-progress-label"><?php e(t('%d of %d completed', $doneCount, $totalCount)); ?> (<?php e((string) $percent); ?>%)</span>
</div>
<?php if ($readonly && $status === SecurityCheckProcess::STATUS_ARCHIVED): ?>
<div class="notice" data-variant="info" role="status">
<p><?php e(t('This security check is archived and read-only.')); ?></p>
</div>
<?php endif; ?>
<!-- Checklist -->
<form method="post" id="security-check-form" data-app-component="security-check-workspace">
<div class="security-steps">
<?php foreach ($steps as $index => $step):
$key = (string) ($step['key'] ?? '');
$kind = (string) ($step['kind'] ?? SecurityCheckProcess::KIND_STEP);
$stepNumber = $index + 1;
?>
<?php if ($kind === SecurityCheckProcess::KIND_TECH_CHECKLIST): ?>
<!-- Step: technical checklist (per product) -->
<article class="security-step security-step-tech">
<header class="security-step-head">
<span class="security-step-number"><?php e((string) $stepNumber); ?></span>
<div>
<h3><?php e(t((string) ($step['label'] ?? ''))); ?></h3>
<p class="app-muted"><?php e(t((string) ($step['description'] ?? ''))); ?></p>
</div>
<?php if (!empty($progress['step6_done'])): ?>
<span class="badge" data-variant="success"><?php e(t('Complete')); ?></span>
<?php endif; ?>
</header>
<?php if ($techItems === []): ?>
<p class="app-muted security-step-empty"><?php e(t('This product has no technical checklist items defined yet.')); ?></p>
<?php else: ?>
<ul class="security-tech-list">
<?php
$snapshotItems = is_array($check['tech_schema_snapshot']['items'] ?? null) ? $check['tech_schema_snapshot']['items'] : [];
foreach ($snapshotItems as $item):
if (!is_array($item)) { continue; }
$itemType = (string) ($item['type'] ?? 'check');
if ($itemType === 'section'): ?>
<li class="security-tech-section"><?php e((string) ($item['label'] ?? '')); ?></li>
<?php else:
$itemKey = (string) ($item['key'] ?? '');
if ($itemKey === '') { continue; }
$entry = is_array($techState[$itemKey] ?? null) ? $techState[$itemKey] : [];
$done = !empty($entry['done']);
$note = (string) ($entry['note'] ?? '');
$meta = $completionMeta($entry);
?>
<li class="security-tech-item<?php if ($done): ?> is-done<?php endif; ?>">
<label class="security-check-toggle">
<input type="checkbox" name="tech[<?php e($itemKey); ?>][done]" value="1"<?php if ($done): ?> checked<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
<span class="security-tech-label">
<?php e((string) ($item['label'] ?? '')); ?>
<?php if (trim((string) ($item['hint'] ?? '')) !== ''): ?>
<small class="app-muted security-tech-hint"><?php e((string) $item['hint']); ?></small>
<?php endif; ?>
</span>
</label>
<?php if ($meta !== ''): ?><span class="security-completion-meta"><i class="bi bi-check2-circle"></i> <?php e($meta); ?></span><?php endif; ?>
<?php if (trim((string) ($item['markdown_html'] ?? '')) !== ''):
$markdownHtml = (string) $item['markdown_html'];
require __DIR__ . '/../../../templates/tech-markdown.phtml';
endif; ?>
<input type="text" class="security-item-note" name="tech[<?php e($itemKey); ?>][note]" value="<?php e($note); ?>" placeholder="<?php e(t('Note (optional)')); ?>"<?php if ($readonly): ?> disabled<?php endif; ?>>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</article>
<?php else:
$entry = is_array($processState[$key] ?? null) ? $processState[$key] : [];
$done = !empty($entry['done']);
$note = (string) ($entry['note'] ?? '');
$meta = $completionMeta($entry);
?>
<article class="security-step<?php if ($done): ?> is-done<?php endif; ?>">
<header class="security-step-head">
<span class="security-step-number"><?php e((string) $stepNumber); ?></span>
<label class="security-check-toggle security-step-toggle">
<input type="checkbox" name="step[<?php e($key); ?>][done]" value="1"<?php if ($done): ?> checked<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
<span>
<strong><?php e(t((string) ($step['label'] ?? ''))); ?></strong>
<small class="app-muted"><?php e(t((string) ($step['description'] ?? ''))); ?></small>
</span>
</label>
<?php if ($meta !== ''): ?><span class="security-completion-meta"><i class="bi bi-check2-circle"></i> <?php e($meta); ?></span><?php endif; ?>
</header>
<?php
$stepFields = is_array($step['fields'] ?? null) ? $step['fields'] : [];
if ($stepFields !== []):
$fieldValues = is_array($entry['fields'] ?? null) ? $entry['fields'] : [];
$hasRequired = false;
?>
<div class="security-step-fields">
<?php foreach ($stepFields as $field):
$fkey = (string) ($field['key'] ?? '');
if ($fkey === '') { continue; }
$ftype = (string) ($field['type'] ?? SecurityCheckProcess::FIELD_TEXTAREA);
$isDatetime = $ftype === SecurityCheckProcess::FIELD_DATETIME;
$fieldRequired = ($field['required'] ?? false) === true;
if ($fieldRequired) { $hasRequired = true; }
$fval = (string) ($fieldValues[$fkey] ?? '');
$fname = 'step[' . $key . '][fields][' . $fkey . ']';
$fid = 'security-field-' . $key . '-' . $fkey;
?>
<div class="security-step-field<?php if ($isDatetime): ?> security-step-field-datetime<?php endif; ?>">
<label for="<?php e($fid); ?>"><?php e(t((string) ($field['label'] ?? ''))); ?><?php if ($fieldRequired): ?> <span class="security-form-required" aria-hidden="true">*</span><?php endif; ?></label>
<?php if ($isDatetime): ?>
<input type="datetime-local" id="<?php e($fid); ?>" name="<?php e($fname); ?>" value="<?php e($fval); ?>"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>>
<?php else: ?>
<textarea id="<?php e($fid); ?>" name="<?php e($fname); ?>" rows="2"<?php if ($fieldRequired): ?> data-security-required="1"<?php endif; ?><?php if ($readonly): ?> disabled<?php endif; ?>><?php e($fval); ?></textarea>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php if ($hasRequired): ?>
<p class="security-step-gate-hint" data-security-gate-hint hidden>
<i class="bi bi-info-circle"></i> <?php e(t('Fill in all required fields to complete this step.')); ?>
</p>
<?php endif; ?>
<?php endif; ?>
<input type="text" class="security-item-note" name="step[<?php e($key); ?>][note]" value="<?php e($note); ?>" placeholder="<?php e(t('Note (optional)')); ?>"<?php if ($readonly): ?> disabled<?php endif; ?>>
<?php if ($key === SecurityCheckProcess::STEP_REPORT): ?>
<div class="security-report-action">
<?php if ($canGenerateReport && $reportPdfUrl !== ''): ?>
<a href="<?php e($reportPdfUrl); ?>" class="secondary outline small" target="_blank" rel="noopener">
<i class="bi bi-file-earmark-pdf" aria-hidden="true"></i> <?php e(t('Generate PDF customer report')); ?>
</a>
<p class="app-muted security-report-hint"><?php e(t('Opens in a new tab and reflects the last saved state.')); ?></p>
<?php else: ?>
<button type="button" class="secondary outline small" disabled>
<i class="bi bi-file-earmark-pdf" aria-hidden="true"></i> <?php e(t('Generate PDF customer report')); ?>
</button>
<p class="app-muted security-report-hint"><i class="bi bi-lock" aria-hidden="true"></i> <?php e(t('Complete and save all previous steps to enable the customer report.')); ?></p>
<?php endif; ?>
</div>
<?php endif; ?>
</article>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!$readonly): ?>
<div class="security-form-actions">
<button type="submit" name="action" value="save" class="primary app-action-success"><?php e(t('Save')); ?></button>
</div>
<?php endif; ?>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
<?php if ($canManage): ?>
<!-- Danger zone -->
<div class="security-danger-zone">
<div class="security-danger-zone-inner">
<h3><?php e(t('Danger zone')); ?></h3>
<div class="security-danger-actions">
<form method="post">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<?php if ($status === SecurityCheckProcess::STATUS_ARCHIVED): ?>
<button type="submit" name="action" value="unarchive" class="secondary outline small"><i class="bi bi-arrow-counterclockwise"></i> <?php e(t('Reopen check')); ?></button>
<?php else: ?>
<button type="submit" name="action" value="archive" class="secondary outline small"><i class="bi bi-archive"></i> <?php e(t('Archive check')); ?></button>
<?php endif; ?>
</form>
<form method="post"
data-detail-confirm-message="<?php e(t('Delete this security check? This cannot be undone.')); ?>"
data-detail-confirm-variant="danger">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" name="action" value="delete" class="danger outline small"><i class="bi bi-trash3"></i> <?php e(t('Delete check')); ?></button>
</form>
</div>
</div>
</div>
<?php endif; ?>
</section>
</div>

View File

@@ -0,0 +1,48 @@
<?php
return gridFilterSchema([
'query' => [
'search' => ['type' => 'string'],
'status' => ['type' => 'string', 'default' => 'all'],
'view' => ['type' => 'string', 'default' => 'active'],
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'order' => [
'type' => 'order',
'allowed' => ['id', 'debitor_name', 'domain_url', 'product_name', 'status', 'created_at', 'updated_at'],
'default' => 'created_at',
],
'dir' => ['type' => 'dir', 'default' => 'desc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search checks...',
'input_id' => 'security-checks-search-input',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'view',
'type' => 'hidden',
],
[
'key' => 'status',
'type' => 'select',
'label' => 'Status',
'input_id' => 'security-checks-status-filter',
'default' => 'all',
'normalize' => 'all_to_empty',
'allowed' => [
['id' => 'all', 'description' => 'All'],
['id' => 'open', 'description' => 'Open'],
['id' => 'in_progress', 'description' => 'In progress'],
['id' => 'completed', 'description' => 'Completed'],
['id' => 'archived', 'description' => 'Archived'],
],
'label_attributes' => ['data-filter-optional' => true],
],
],
]);

View File

@@ -0,0 +1,53 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
$request = requestInput();
$query = $request->queryAll();
$filterSchema = require __DIR__ . '/filter-schema.php';
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'query' => $query,
'search_keys' => ['search'],
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];
$schemaByKey = $listFilterContext['schemaByKey'];
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'status' => [
'label' => t('Status'),
'type' => 'select',
'default' => (string) ($schemaByKey['status']['default'] ?? 'all'),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])),
],
];
$session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0);
$authService = app(AuthorizationService::class);
$canCreate = $authService->authorize(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE, ['actor_user_id' => $userId])->isAllowed();
$activeView = in_array($query['view'] ?? '', ['active', 'done'], true) ? $query['view'] : 'active';
Buffer::set('title', t('Security checks'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Security checks')],
];

View File

@@ -0,0 +1,65 @@
<?php
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$canCreate = $canCreate ?? false;
$activeView = $activeView ?? 'active';
?>
<?php
$listTitle = t('Security checks');
ob_start();
?>
<?php if ($canCreate): ?>
<a role="button" class="app-action-success" href="<?php e(requestPathWithReturnTarget('security/checks/create', \MintyPHP\Http\Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('New security check')); ?>
</a>
<?php endif; ?>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<div class="app-tabs-nav" style="margin-bottom: 12px;">
<a
href="<?php e(lurl('security/checks') . '?view=active'); ?>"
role="button"
class="<?php e($activeView === 'active' ? 'primary small' : 'secondary outline small'); ?>"
><?php e(t('In progress')); ?></a>
<a
href="<?php e(lurl('security/checks') . '?view=done'); ?>"
role="button"
class="<?php e($activeView === 'done' ? 'primary small' : 'secondary outline small'); ?>"
><?php e(t('Done')); ?></a>
</div>
<?php
$filterUiNamespace = 'security-checks';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="security-checks-grid"></div>
</div>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-security-checks"><?php gridJsonForJs([
'dataUrl' => lurl('security/checks-data') . '?view=' . rawurlencode($activeView),
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'editBaseUrl' => lurl('security/checks/edit/'),
'activeView' => $activeView,
'labels' => [
'customer' => t('Customer'),
'domain' => t('Domain'),
'product' => t('Software product'),
'status' => t('Status'),
'owner' => t('Owner'),
'createdAt' => t('Created'),
],
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/security/js/pages/security-checks-index.js')); ?>"></script>

View File

@@ -0,0 +1,82 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\I18n;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
// Streams the generated PDF directly — no view template for this route.
define('MINTY_ALLOW_OUTPUT', true);
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_VIEW);
$checkId = (int) ($id ?? 0);
$closeTarget = requestResolveReturnTarget('security/checks');
$editTarget = requestPathWithReturnTarget('security/checks/edit/' . $checkId, requestResolveReturnTarget());
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$service = app(SecurityCheckService::class);
$process = app(SecurityCheckProcess::class);
$check = $service->findById($tenantId, $checkId);
if ($check === null) {
Flash::error(t('Security check not found'), $closeTarget, 'security_check_not_found');
Router::redirect($closeTarget);
return;
}
// Re-enforce the gate server-side: the report can only be produced once the
// check has actually been carried out (all preceding steps complete). The UI
// hides the button, but it must not be the only line of defence.
$prerequisitesMet = $process->reportPrerequisitesMet(
is_array($check['process_state'] ?? null) ? $check['process_state'] : [],
is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : [],
is_array($check['tech_state'] ?? null) ? $check['tech_state'] : []
);
if (!$prerequisitesMet) {
Flash::error(t('Complete all previous steps before generating the customer report.'), $editTarget, 'security_report_gated');
Router::redirect($editTarget);
return;
}
$pdfService = app(SecurityReportPdfService::class);
$pdf = $pdfService->renderCustomerReportPdf($check, [
'locale' => strtolower((string) (I18n::$locale ?? I18n::$defaultLocale)),
'app_name' => appTitle(),
'tenant_uuid' => (string) ($session['current_tenant']['uuid'] ?? ''),
]);
if ($pdf === '') {
// Concrete renderer/template failures are handled inside the service.
http_response_code(500);
return;
}
$filename = $pdfService->buildReportFilename($check);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $filename . '"');
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' . strlen($pdf));
$output = fopen('php://output', 'wb');
if ($output === false) {
http_response_code(500);
return;
}
fwrite($output, $pdf);
fclose($output);

View File

@@ -0,0 +1,47 @@
<?php
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityBcGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE);
gridRequireGetRequest();
$request = requestInput();
$debitorNo = trim((string) $request->query('debitor_no', ''));
if ($debitorNo === '') {
Router::json([]);
return;
}
try {
$rows = app(SecurityBcGateway::class)->listDomainsForCustomer($debitorNo);
} catch (\Throwable) {
http_response_code(502);
Router::json(['error' => 'lookup_failed']);
return;
}
$items = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$no = trim((string) ($row['No'] ?? ''));
$url = trim((string) ($row['URL'] ?? ''));
if ($no === '') {
continue;
}
$items[] = [
'value' => $no,
'label' => $url !== '' ? $url : $no,
'url' => $url,
];
}
Router::json($items);

View File

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityBcGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_CHECKS_CREATE);
gridRequireGetRequest();
$request = requestInput();
$query = trim((string) $request->query('q', ''));
if ($query === '' || mb_strlen($query) < 2) {
Router::json([]);
return;
}
try {
$rows = app(SecurityBcGateway::class)->searchCustomers($query);
} catch (\Throwable) {
http_response_code(502);
Router::json(['error' => 'search_failed']);
return;
}
$items = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$no = trim((string) ($row['No'] ?? ''));
$name = trim((string) ($row['Name'] ?? ''));
if ($no === '') {
continue;
}
$items[] = [
'value' => $no,
'label' => $no . ' — ' . $name,
'name' => $name,
'meta' => trim((string) ($row['City'] ?? '')),
];
}
Router::json($items);

View File

@@ -0,0 +1,89 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
$request = requestInput();
$settingsGateway = app(SecurityBcSettingsGateway::class);
$tokenRepository = app(SecurityTokenRepository::class);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), 'security/settings', 'csrf_expired');
Router::redirect('security/settings');
return;
}
if ($request->isMethod('POST')) {
$post = $request->bodyAll();
$authMode = trim((string) ($post['auth_mode'] ?? SecurityBcSettingsGateway::AUTH_MODE_BASIC));
$odataBaseUrl = trim((string) ($post['odata_base_url'] ?? ''));
$companyName = trim((string) ($post['company_name'] ?? ''));
$basicUser = trim((string) ($post['basic_user'] ?? ''));
$basicPassword = trim((string) ($post['basic_password'] ?? ''));
$oauthTenantId = trim((string) ($post['oauth_tenant_id'] ?? ''));
$oauthClientId = trim((string) ($post['oauth_client_id'] ?? ''));
$oauthClientSecret = trim((string) ($post['oauth_client_secret'] ?? ''));
$oauthTokenEndpoint = trim((string) ($post['oauth_token_endpoint'] ?? ''));
$errorBag = formErrors();
if ($odataBaseUrl !== '' && !preg_match('#^https://#i', $odataBaseUrl)) {
$errorBag->add('odata_base_url', t('OData Base URL must use HTTPS'));
}
if ($oauthTokenEndpoint !== '' && !preg_match('#^https://#i', $oauthTokenEndpoint)) {
$errorBag->add('oauth_token_endpoint', t('Token endpoint must use HTTPS'));
}
if (!$errorBag->hasAny()) {
$settingsGateway->setAuthMode($authMode);
$settingsGateway->setODataBaseUrl($odataBaseUrl !== '' ? $odataBaseUrl : null);
$settingsGateway->setCompanyName($companyName !== '' ? $companyName : null);
$settingsGateway->setBasicUser($basicUser !== '' ? $basicUser : null);
if ($basicPassword !== '') {
$settingsGateway->setBasicPassword($basicPassword);
}
$settingsGateway->setOAuthTenantId($oauthTenantId !== '' ? $oauthTenantId : null);
$settingsGateway->setOAuthClientId($oauthClientId !== '' ? $oauthClientId : null);
if ($oauthClientSecret !== '') {
$settingsGateway->setOAuthClientSecret($oauthClientSecret);
}
$settingsGateway->setOAuthTokenEndpoint($oauthTokenEndpoint !== '' ? $oauthTokenEndpoint : null);
$tokenRepository->deleteAll();
Flash::success(t('Settings saved'), 'security/settings', 'settings_saved');
} else {
flashFormErrors($errorBag, 'security/settings', 'settings_error');
}
Router::redirect('security/settings');
return;
}
$authMode = $settingsGateway->getAuthMode();
$odataBaseUrl = $settingsGateway->getODataBaseUrl();
$companyName = $settingsGateway->getCompanyName();
$basicUser = $settingsGateway->getBasicUser() ?? '';
$hasBasicPassword = $settingsGateway->getBasicPassword() !== null;
$oauthTenantId = $settingsGateway->getOAuthTenantId() ?? '';
$oauthClientId = $settingsGateway->getOAuthClientId() ?? '';
$hasOAuthClientSecret = $settingsGateway->getOAuthClientSecret() !== null;
$oauthTokenEndpoint = $settingsGateway->getOAuthTokenEndpoint() ?? '';
$configErrors = $settingsGateway->validateConfiguration();
Buffer::set('title', t('Security settings'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Settings')],
];

View File

@@ -0,0 +1,112 @@
<?php
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
$authMode = (string) ($authMode ?? SecurityBcSettingsGateway::AUTH_MODE_BASIC);
$odataBaseUrl = (string) ($odataBaseUrl ?? '');
$companyName = (string) ($companyName ?? '');
$basicUser = (string) ($basicUser ?? '');
$hasBasicPassword = (bool) ($hasBasicPassword ?? false);
$oauthTenantId = (string) ($oauthTenantId ?? '');
$oauthClientId = (string) ($oauthClientId ?? '');
$hasOAuthClientSecret = (bool) ($hasOAuthClientSecret ?? false);
$oauthTokenEndpoint = (string) ($oauthTokenEndpoint ?? '');
$configErrors = is_array($configErrors ?? null) ? $configErrors : [];
$isOauth = $authMode === SecurityBcSettingsGateway::AUTH_MODE_OAUTH2;
?>
<div class="app-details-container">
<section data-tab-panel>
<?php
$titlebar = [
'title' => t('Security settings'),
'actions' => [
['label' => t('Save'), 'type' => 'submit', 'form' => 'security-settings-form', 'class' => 'primary', 'tone' => 'success'],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<?php if ($configErrors !== []): ?>
<div class="notice" data-variant="warning" role="status">
<p><strong><?php e(t('Connection not fully configured')); ?></strong></p>
<ul>
<?php foreach ($configErrors as $missing): ?><li><?php e((string) $missing); ?></li><?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="post" id="security-settings-form" data-app-component="security-settings">
<div class="app-details-card">
<h3><?php e(t('Business Central connection')); ?></h3>
<div class="security-field">
<label for="security-auth-mode"><?php e(t('Authentication mode')); ?></label>
<select id="security-auth-mode" name="auth_mode" data-security-auth-mode>
<option value="basic"<?php if (!$isOauth): ?> selected<?php endif; ?>><?php e(t('Basic authentication')); ?></option>
<option value="oauth2"<?php if ($isOauth): ?> selected<?php endif; ?>><?php e(t('OAuth2 (client credentials)')); ?></option>
</select>
</div>
<div class="security-field">
<label for="security-odata-url"><?php e(t('OData base URL')); ?></label>
<input type="url" id="security-odata-url" name="odata_base_url" value="<?php e($odataBaseUrl); ?>" placeholder="https://bc.example.com:7048/BC/ODataV4">
</div>
<div class="security-field">
<label for="security-company"><?php e(t('Company name')); ?></label>
<input type="text" id="security-company" name="company_name" value="<?php e($companyName); ?>" autocomplete="off">
</div>
</div>
<div class="app-details-card" data-security-auth-section="basic"<?php if ($isOauth): ?> hidden<?php endif; ?>>
<h3><?php e(t('Basic authentication')); ?></h3>
<div class="security-field">
<label for="security-basic-user"><?php e(t('Username')); ?></label>
<input type="text" id="security-basic-user" name="basic_user" value="<?php e($basicUser); ?>" autocomplete="off">
</div>
<div class="security-field">
<label for="security-basic-pass"><?php e(t('Password')); ?></label>
<input type="password" id="security-basic-pass" name="basic_password" value="" autocomplete="new-password"
placeholder="<?php e($hasBasicPassword ? t('•••••••• (leave blank to keep)') : ''); ?>">
</div>
</div>
<div class="app-details-card" data-security-auth-section="oauth2"<?php if (!$isOauth): ?> hidden<?php endif; ?>>
<h3><?php e(t('OAuth2 (client credentials)')); ?></h3>
<div class="security-field">
<label for="security-oauth-token-endpoint"><?php e(t('Token endpoint')); ?></label>
<input type="url" id="security-oauth-token-endpoint" name="oauth_token_endpoint" value="<?php e($oauthTokenEndpoint); ?>"
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token">
</div>
<div class="security-field">
<label for="security-oauth-tenant"><?php e(t('Directory (tenant) ID')); ?></label>
<input type="text" id="security-oauth-tenant" name="oauth_tenant_id" value="<?php e($oauthTenantId); ?>" autocomplete="off">
</div>
<div class="security-field">
<label for="security-oauth-client"><?php e(t('Client ID')); ?></label>
<input type="text" id="security-oauth-client" name="oauth_client_id" value="<?php e($oauthClientId); ?>" autocomplete="off">
</div>
<div class="security-field">
<label for="security-oauth-secret"><?php e(t('Client secret')); ?></label>
<input type="password" id="security-oauth-secret" name="oauth_client_secret" value="" autocomplete="new-password"
placeholder="<?php e($hasOAuthClientSecret ? t('•••••••• (leave blank to keep)') : ''); ?>">
</div>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
<div class="app-details-card">
<h3><?php e(t('Test connection')); ?></h3>
<p class="app-muted"><?php e(t('Save your settings first, then test the connection to Business Central.')); ?></p>
<button type="button" class="secondary outline" data-security-test-connection
data-url="<?php e(lurl('security/settings/test-connection-data')); ?>"
data-text-testing="<?php e(t('Testing...')); ?>"
data-text-ok="<?php e(t('Connection successful')); ?>"
data-text-fail="<?php e(t('Connection failed')); ?>">
<i class="bi bi-plug"></i> <?php e(t('Test connection')); ?>
</button>
<span data-security-test-result role="status" aria-live="polite"></span>
</div>
</section>
</div>

View File

@@ -0,0 +1,24 @@
<?php
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityBcGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
gridRequireGetRequest();
$result = app(SecurityBcGateway::class)->testConnection();
if (($result['ok'] ?? false) === true) {
Router::json(['ok' => true, 'http_code' => (int) ($result['http_code'] ?? 200)]);
return;
}
http_response_code(200);
Router::json([
'ok' => false,
'error' => (string) ($result['error'] ?? 'Connection failed'),
]);

View File

@@ -0,0 +1,41 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/templates/filter-schema.php');
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$result = app(SecurityCheckTemplateService::class)->listPaged($tenantId, $filters);
$rows = $result['rows'] ?? [];
$total = (int) ($result['total'] ?? 0);
$editBaseUrl = lurl('security/templates/edit/');
$preparedRows = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
$items = SecurityCheckProcess::techCheckItems(
is_array(json_decode((string) ($row['tech_schema_json'] ?? ''), true)) ? json_decode((string) ($row['tech_schema_json'] ?? ''), true) : []
);
$preparedRows[] = [
'id' => $id,
'product_code' => (string) ($row['product_code'] ?? ''),
'product_name' => (string) ($row['product_name'] ?? ''),
'item_count' => count($items),
'active' => (int) ($row['active'] ?? 0) === 1,
'updated_at' => (string) ($row['updated_at'] ?? ($row['created_at'] ?? '')),
'edit_url' => $id > 0 ? $editBaseUrl . $id : '',
];
}
gridJsonDataResult($preparedRows, $total);

View File

@@ -0,0 +1,62 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
$request = requestInput();
$returnTarget = requestResolveReturnTarget('security/templates');
$closeTarget = requestResolveReturnTarget('security/templates');
$createTarget = requestPathWithReturnTarget('security/templates/create', $returnTarget);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(SecurityCheckTemplateService::class);
$errorBag = formErrors();
$form = [
'product_code' => (string) $request->body('product_code', ''),
'product_name' => (string) $request->body('product_name', ''),
];
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
Router::redirect($createTarget);
return;
}
$result = $service->create($tenantId, $form['product_code'], $form['product_name'], $userId);
if ($result['ok'] ?? false) {
$editUrl = lurl('security/templates/edit/' . (int) $result['id']);
Flash::success(t('Template created'), $editUrl, 'template_created');
Router::redirect($editUrl);
return;
}
$errorBag->merge($result['errors'] ?? []);
}
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
Buffer::set('title', t('New template'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Check templates'), 'path' => 'security/templates'],
['label' => t('New template')],
];

View File

@@ -0,0 +1,50 @@
<?php
$form = is_array($form ?? null) ? $form : [];
$errors = is_array($errors ?? null) ? $errors : [];
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
$closeTarget = $closeTarget ?? 'security/templates';
?>
<div class="app-wizard-content">
<div class="app-wizard-header">
<a href="<?php e(lurl($closeTarget)); ?>" class="app-wizard-back" data-tooltip="<?php e(t('Back to list')); ?>">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="app-wizard-title"><?php e(t('New check template')); ?></h1>
</div>
<?php if ($validationSummaryErrors): ?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php endif; ?>
<div class="app-wizard-card">
<form method="post" id="security-template-create-form">
<h2 class="app-wizard-card-heading"><?php e(t('Create a check template')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('A template represents one software product and its technical checklist. You can add checklist items after creating it.')); ?></p>
<div class="app-wizard-field-group">
<label for="security-template-code"><?php e(t('Product code')); ?> <span class="security-form-required">*</span></label>
<input type="text" id="security-template-code" name="product_code" value="<?php e($form['product_code'] ?? ''); ?>"
placeholder="<?php e(t('e.g. intranet, mysyde-cms')); ?>" autocomplete="off">
<?php if (!empty($errors['product_code'])): ?>
<p class="field-error"><?php e($errors['product_code']); ?></p>
<?php endif; ?>
</div>
<div class="app-wizard-field-group">
<label for="security-template-name"><?php e(t('Product name')); ?> <span class="security-form-required">*</span></label>
<input type="text" id="security-template-name" name="product_name" value="<?php e($form['product_name'] ?? ''); ?>"
placeholder="<?php e(t('e.g. Intranet, Mysyde CMS')); ?>" autocomplete="off">
<?php if (!empty($errors['product_name'])): ?>
<p class="field-error"><?php e($errors['product_name']); ?></p>
<?php endif; ?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"><?php e(t('Create template')); ?></button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,93 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
$request = requestInput();
$returnTarget = requestResolveReturnTarget();
$closeTarget = requestResolveReturnTarget('security/templates');
$templateId = (int) ($id ?? 0);
$editTarget = requestPathWithReturnTarget('security/templates/edit/' . $templateId, $returnTarget);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(SecurityCheckTemplateService::class);
$template = $service->findById($tenantId, $templateId);
if ($template === null) {
Flash::error(t('Template not found'), $closeTarget, 'template_not_found');
Router::redirect($closeTarget);
return;
}
$errorBag = formErrors();
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
Router::redirect($editTarget);
return;
}
$name = (string) $request->body('product_name', '');
$active = $request->body('active', '') !== '';
$metaResult = $service->updateMeta($tenantId, $templateId, $name, $active, $userId);
$errorBag->merge($metaResult['errors'] ?? []);
$items = [];
$schemaJson = (string) $request->body('tech_schema_json', '');
if ($schemaJson !== '') {
$decoded = json_decode($schemaJson, true);
if (is_array($decoded)) {
$items = $decoded;
}
}
$schemaResult = $service->saveTechSchema($tenantId, $templateId, $items, $userId);
$errorBag->merge($schemaResult['errors'] ?? []);
if (!$errorBag->hasAny()) {
$action = (string) $request->body('action', 'save');
$target = $action === 'save_close' ? $closeTarget : $editTarget;
Flash::success(t('Template saved'), $target, 'template_saved');
Router::redirect($target);
return;
}
flashFormErrors($errorBag, $editTarget, 'template_save_failed');
Router::redirect($editTarget);
return;
}
$schema = $service->decodeSchema($template);
$schemaItems = $schema['items'] ?? [];
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
$titleText = trim((string) ($template['product_name'] ?? '')) !== ''
? (string) $template['product_name']
: t('Edit template');
Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Check templates'), 'path' => 'security/templates'],
['label' => $titleText],
];

View File

@@ -0,0 +1,74 @@
<?php
$template = is_array($template ?? null) ? $template : [];
$schemaItems = is_array($schemaItems ?? null) ? $schemaItems : [];
$errors = is_array($errors ?? null) ? $errors : [];
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
$closeTarget = $closeTarget ?? 'security/templates';
$isActive = (int) ($template['active'] ?? 1) === 1;
?>
<div class="app-details-container">
<section data-tab-panel data-tab-panel-wide>
<?php
$titlebar = [
'title' => trim((string) ($template['product_name'] ?? '')) !== '' ? (string) $template['product_name'] : t('Edit template'),
'backHref' => lurl($closeTarget),
'actions' => [
['label' => t('Save'), 'type' => 'submit', 'form' => 'security-template-form', 'name' => 'action', 'value' => 'save', 'class' => 'primary', 'tone' => 'success'],
['label' => t('Save & close'), 'type' => 'submit', 'form' => 'security-template-form', 'name' => 'action', 'value' => 'save_close', 'class' => 'secondary outline'],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<?php if ($validationSummaryErrors): ?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php endif; ?>
<form method="post" id="security-template-form">
<div class="app-details-card">
<div class="security-field-row">
<div class="security-field">
<label><?php e(t('Product code')); ?></label>
<input type="text" value="<?php e((string) ($template['product_code'] ?? '')); ?>" disabled>
<small class="muted"><?php e(t('The product code cannot be changed after creation.')); ?></small>
</div>
<div class="security-field">
<label for="security-template-name"><?php e(t('Product name')); ?> <span class="security-form-required">*</span></label>
<input type="text" id="security-template-name" name="product_name" value="<?php e((string) ($template['product_name'] ?? '')); ?>" autocomplete="off">
<?php if (!empty($errors['product_name'])): ?><p class="field-error"><?php e($errors['product_name']); ?></p><?php endif; ?>
</div>
</div>
<label class="security-check-toggle security-active-toggle">
<input type="checkbox" name="active" value="1"<?php if ($isActive): ?> checked<?php endif; ?>>
<span><?php e(t('Active (selectable when creating a security check)')); ?></span>
</label>
</div>
<div class="app-details-card">
<h3><?php e(t('Technical checklist')); ?></h3>
<p class="app-muted"><?php e(t('Define the product-specific technical checks. Add sections to group items. Items are frozen onto each security check at creation time.')); ?></p>
<?php if (!empty($errors['schema'])): ?><p class="field-error"><?php e($errors['schema']); ?></p><?php endif; ?>
<div id="security-schema-editor" data-app-component="security-template-schema-editor"
data-input-name="tech_schema_json"
data-label-section="<?php e(t('Section')); ?>"
data-label-check="<?php e(t('Check item')); ?>"
data-label-add-section="<?php e(t('Add section')); ?>"
data-label-add-check="<?php e(t('Add check item')); ?>"
data-label-remove="<?php e(t('Remove')); ?>"
data-label-move-up="<?php e(t('Move up')); ?>"
data-label-move-down="<?php e(t('Move down')); ?>"
data-placeholder-section="<?php e(t('Section title')); ?>"
data-placeholder-check="<?php e(t('Check item label')); ?>"
data-placeholder-hint="<?php e(t('Hint (optional)')); ?>"
data-placeholder-markdown="<?php e(t('Markdown guidance (optional) — shown in the checklist')); ?>"
data-empty-text="<?php e(t('No checklist items yet. Add a check item to get started.')); ?>">
</div>
<input type="hidden" name="tech_schema_json" id="security-schema-json" value="">
<script type="application/json" id="security-schema-initial"><?php gridJsonForJs(array_values($schemaItems)); ?></script>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
</section>
</div>

View File

@@ -0,0 +1,41 @@
<?php
return gridFilterSchema([
'query' => [
'search' => ['type' => 'string'],
'active' => ['type' => 'string', 'default' => 'all'],
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'order' => [
'type' => 'order',
'allowed' => ['id', 'product_code', 'product_name', 'active', 'version', 'updated_at'],
'default' => 'product_name',
],
'dir' => ['type' => 'dir', 'default' => 'asc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search templates...',
'input_id' => 'security-templates-search-input',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'active',
'type' => 'select',
'label' => 'Active',
'input_id' => 'security-templates-active-filter',
'default' => 'all',
'normalize' => 'all_to_empty',
'allowed' => [
['id' => 'all', 'description' => 'All'],
['id' => '1', 'description' => 'Active'],
['id' => '0', 'description' => 'Inactive'],
],
'label_attributes' => ['data-filter-optional' => true],
],
],
]);

View File

@@ -0,0 +1,41 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_TEMPLATES_MANAGE);
$request = requestInput();
$query = $request->queryAll();
$filterSchema = require __DIR__ . '/filter-schema.php';
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'query' => $query,
'search_keys' => ['search'],
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];
$schemaByKey = $listFilterContext['schemaByKey'];
$filterChipMeta = [
'search' => ['label' => t('Search'), 'type' => 'text'],
'active' => [
'label' => t('Active'),
'type' => 'select',
'default' => (string) ($schemaByKey['active']['default'] ?? 'all'),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['active'] ?? [])),
],
];
Buffer::set('title', t('Check templates'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Check templates')],
];

View File

@@ -0,0 +1,48 @@
<?php
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
?>
<?php
$listTitle = t('Check templates');
ob_start();
?>
<a role="button" class="app-action-success" href="<?php e(requestPathWithReturnTarget('security/templates/create', \MintyPHP\Http\Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('New template')); ?>
</a>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'security-templates';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="security-templates-grid"></div>
</div>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-security-templates"><?php gridJsonForJs([
'dataUrl' => lurl('security/templates-data'),
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'editBaseUrl' => lurl('security/templates/edit/'),
'labels' => [
'productName' => t('Product'),
'productCode' => t('Code'),
'itemCount' => t('Checklist items'),
'active' => t('Active'),
'updatedAt' => t('Updated'),
'yes' => t('Yes'),
'no' => t('No'),
],
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/security/js/pages/security-templates-index.js')); ?>"></script>

View File

@@ -0,0 +1,82 @@
<?php
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$securityNav = is_array($layoutNav['security.nav'] ?? null) ? $layoutNav['security.nav'] : [];
$canViewChecks = !empty($securityNav['can_view_checks']);
$canManageTemplates = !empty($securityNav['can_manage_templates']);
$canManageSettings = !empty($securityNav['can_manage_settings']);
$checksActive = navActive(['security/checks', 'security/checks/create', 'security/checks/edit'], true);
$templatesActive = navActive(['security/templates', 'security/templates/create', 'security/templates/edit'], true);
$settingsActive = navActive('security/settings', true);
$securityNavGroups = [
[
'key' => 'security-operations',
'label' => t('Operations'),
'icon' => 'bi-clipboard-check',
'items' => [
[
'label' => t('Security checks'),
'path' => 'security/checks',
'active' => $checksActive,
'visible' => $canViewChecks,
],
],
],
[
'key' => 'security-administration',
'label' => t('Administration'),
'icon' => 'bi-sliders',
'items' => [
[
'label' => t('Check templates'),
'path' => 'security/templates',
'active' => $templatesActive,
'visible' => $canManageTemplates,
],
[
'label' => t('Settings'),
'path' => 'security/settings',
'active' => $settingsActive,
'visible' => $canManageSettings,
],
],
],
];
?>
<ul>
<?php foreach ($securityNavGroups as $group):
$visibleItems = array_values(array_filter($group['items'], static fn (array $item): bool => !empty($item['visible'])));
if (!$visibleItems) {
continue;
}
$groupIsActive = false;
foreach ($visibleItems as $item) {
if (!empty(($item['active'] ?? [])['isActive'])) {
$groupIsActive = true;
break;
}
}
?>
<li class="app-sidebar-group app-sidebar-admin-group">
<details data-details-key="<?php e($group['key']); ?>"<?php if ($groupIsActive): ?> open<?php endif; ?>>
<summary>
<i class="bi <?php e($group['icon']); ?>"></i>
<span><?php e($group['label']); ?></span>
</summary>
<ul>
<?php foreach ($visibleItems as $item):
$active = $item['active'] ?? ['class' => '', 'aria' => ''];
?>
<li>
<a href="<?php e(lurl($item['path'])); ?>" class="<?php e($active['class'] ?? ''); ?>" <?php echo $active['aria'] ?? ''; // raw-html-ok: pre-built ARIA attribute from navActive() ?>>
<?php e($item['label']); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</details>
</li>
<?php endforeach; ?>
</ul>

View File

@@ -0,0 +1,123 @@
<?php
/**
* Customer-facing security check report (Dompdf).
*
* Hydrated as an isolated HTML string by SecurityReportPdfService — it only
* sees $report (the flat, pre-built context). All dynamic values are escaped
* with e(); labels go through t() so the report follows the request locale.
*
* @var array<string, mixed> $report
*/
$report = is_array($report ?? null) ? $report : [];
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
$steps = is_array($report['steps'] ?? null) ? $report['steps'] : [];
$techSections = is_array($report['tech_sections'] ?? null) ? $report['tech_sections'] : [];
$percent = (int) ($summary['percent'] ?? 0);
$techDone = (int) ($summary['tech_done'] ?? 0);
$techTotal = (int) ($summary['tech_total'] ?? 0);
$appName = trim((string) ($report['app_name'] ?? ''));
?>
<!doctype html>
<html lang="<?php e((string) (\MintyPHP\I18n::$locale ?? 'en')); ?>">
<head>
<meta charset="utf-8">
<title><?php e((string) ($report['title'] ?? '')); ?></title>
<style>
* { box-sizing: border-box; }
body { font-family: "DejaVu Sans", Arial, sans-serif; color: #1a1a1a; font-size: 12px; line-height: 1.45; margin: 0; }
h1 { font-size: 20px; margin: 0 0 2px; }
h2 { font-size: 14px; margin: 22px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #e2e2e2; }
.muted { color: #6a6a6a; }
.header { border-bottom: 2px solid #1a1a1a; padding-bottom: 12px; margin-bottom: 16px; }
.header-logo { max-height: 48px; max-width: 200px; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; }
.meta td { padding: 3px 0; vertical-align: top; }
.meta td.label { color: #6a6a6a; width: 38%; }
.summary { margin: 6px 0 4px; font-size: 13px; }
.bar { height: 10px; background: #ececec; border-radius: 5px; overflow: hidden; margin-top: 6px; }
.bar-fill { height: 10px; background: #2e7d32; }
.checklist td { padding: 6px 8px; border-bottom: 1px solid #ededed; vertical-align: top; }
.checklist td.state { width: 70px; white-space: nowrap; }
.section-row td { background: #f5f5f5; font-weight: bold; padding: 6px 8px; }
.ok { color: #2e7d32; font-weight: bold; }
.pending { color: #b26a00; font-weight: bold; }
.note { color: #6a6a6a; font-size: 11px; margin-top: 2px; }
.footer { margin-top: 26px; padding-top: 8px; border-top: 1px solid #e2e2e2; color: #8a8a8a; font-size: 10px; }
</style>
</head>
<body>
<div class="header">
<img src="<?php e((string) ($report['logo_data_uri'] ?? '')); ?>" alt="" class="header-logo">
<h1><?php e((string) ($report['title'] ?? '')); ?></h1>
<div class="muted"><?php e(t('Generated on')); ?> <?php e((string) ($report['generated_at'] ?? '')); ?></div>
</div>
<table class="meta">
<tr><td class="label"><?php e(t('Customer')); ?></td><td><?php e((string) ($report['customer_name'] ?? '')); ?><?php if (($report['customer_no'] ?? '') !== ''): ?> <span class="muted">(<?php e((string) $report['customer_no']); ?>)</span><?php endif; ?></td></tr>
<tr><td class="label"><?php e(t('Domain')); ?></td><td><?php e((string) ($report['domain'] ?? '')); ?></td></tr>
<tr><td class="label"><?php e(t('Software product')); ?></td><td><?php e((string) ($report['product'] ?? '')); ?></td></tr>
<tr><td class="label"><?php e(t('Status')); ?></td><td><?php e((string) ($report['status_label'] ?? '')); ?></td></tr>
</table>
<h2><?php e(t('Result')); ?></h2>
<div class="summary"><?php e(t('%d of %d checks completed', $techDone, $techTotal)); ?> (<?php e((string) $percent); ?>%)</div>
<div class="bar"><div class="bar-fill" style="width: <?php e((string) $percent); ?>%;"></div></div>
<h2><?php e(t('Technical checklist')); ?></h2>
<?php if ($techSections === []): ?>
<p class="muted"><?php e(t('This product has no technical checklist items defined yet.')); ?></p>
<?php else: ?>
<table class="checklist">
<?php foreach ($techSections as $section): ?>
<?php if (trim((string) ($section['label'] ?? '')) !== ''): ?>
<tr class="section-row"><td colspan="2"><?php e((string) $section['label']); ?></td></tr>
<?php endif; ?>
<?php foreach (($section['items'] ?? []) as $item): ?>
<tr>
<td class="state">
<?php if (!empty($item['done'])): ?>
<span class="ok">&#10003; <?php e(t('Completed')); ?></span>
<?php else: ?>
<span class="pending"><?php e(t('Not completed')); ?></span>
<?php endif; ?>
</td>
<td>
<?php e((string) ($item['label'] ?? '')); ?>
<?php if (trim((string) ($item['note'] ?? '')) !== ''): ?>
<div class="note"><?php e((string) $item['note']); ?></div>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endforeach; ?>
</table>
<?php endif; ?>
<h2><?php e(t('Process')); ?></h2>
<table class="checklist">
<?php foreach ($steps as $step): ?>
<tr>
<td class="state">
<?php if (!empty($step['done'])): ?>
<span class="ok">&#10003; <?php e(t('Completed')); ?></span>
<?php else: ?>
<span class="pending"><?php e(t('Open')); ?></span>
<?php endif; ?>
</td>
<td>
<?php e((string) ($step['number'] ?? '')); ?>. <?php e((string) ($step['label'] ?? '')); ?>
<?php if (trim((string) ($step['completed_at'] ?? '')) !== ''): ?>
<span class="muted">&middot; <?php e((string) $step['completed_at']); ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<div class="footer">
<?php if ($appName !== ''): ?><?php e($appName); ?> &middot; <?php endif; ?><?php e((string) ($report['title'] ?? '')); ?> &middot; <?php e((string) ($report['generated_at'] ?? '')); ?>
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<?php
/**
* Renders a single checkpoint's pre-sanitized Markdown guidance, collapsed by
* default inside a <details> toggle.
*
* Lives in a partial (not the routed view) so the framework Analyzer permits the
* raw echo. The HTML is produced by SecurityMarkdownGateway with html_input=escape
* and unsafe links dropped, so it is safe to output verbatim.
*
* @var string $markdownHtml
*/
$markdownHtml = (string) ($markdownHtml ?? '');
if ($markdownHtml === '') {
return;
}
?>
<details class="security-tech-markdown">
<summary><i class="bi bi-info-circle" aria-hidden="true"></i> <?php e(t('Guidance')); ?></summary>
<div class="security-tech-markdown-body"><?php echo $markdownHtml; // raw-html-ok: sanitized Markdown (html_input=escape, unsafe links dropped) via SecurityMarkdownGateway ?></div>
</details>

View File

@@ -0,0 +1,54 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Security\Service\SecurityBcGateway;
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
use PHPUnit\Framework\TestCase;
class SecurityBcGatewayTest extends TestCase
{
private function makeGateway(bool $configured = false): SecurityBcGateway
{
$settings = $this->createMock(SecurityBcSettingsGateway::class);
$settings->method('isConfigured')->willReturn($configured);
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_BASIC);
$oauth = $this->createMock(SecurityOAuthTokenService::class);
$session = $this->createMock(SessionStoreInterface::class);
$session->method('all')->willReturn([]);
return new SecurityBcGateway($settings, $oauth, $session);
}
public function testSearchCustomersReturnsEmptyForBlankQuery(): void
{
$this->assertSame([], $this->makeGateway()->searchCustomers(' '));
}
public function testListDomainsForCustomerReturnsEmptyForBlankNumber(): void
{
$this->assertSame([], $this->makeGateway()->listDomainsForCustomer(''));
}
public function testListDomainsForCustomerReturnsEmptyWhenNotConfigured(): void
{
// request() short-circuits to null before any network call when unconfigured.
$this->assertSame([], $this->makeGateway(false)->listDomainsForCustomer('D10001'));
}
public function testSearchCustomersRejectsInvalidCharacters(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->makeGateway(true)->searchCustomers("bad'); DROP TABLE--");
}
public function testTestConnectionReportsIncompleteConfiguration(): void
{
$result = $this->makeGateway(false)->testConnection();
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('error', $result);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SecurityBcSettingsGatewayTest extends TestCase
{
/**
* @param array<string, string> $values
*/
private function makeGateway(array $values = []): SecurityBcSettingsGateway
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null);
$metadata->method('set')->willReturn(true);
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
$crypto->method('encryptString')->willReturnCallback(static fn (string $v): string => 'enc:' . $v);
$crypto->method('decryptString')->willReturnCallback(static fn (string $v): string => str_replace('enc:', '', $v));
return new SecurityBcSettingsGateway($metadata, $crypto);
}
public function testAuthModeDefaultsToBasic(): void
{
$gateway = $this->makeGateway();
$this->assertSame(SecurityBcSettingsGateway::AUTH_MODE_BASIC, $gateway->getAuthMode());
}
public function testAuthModeReturnsOauth2WhenSet(): void
{
$gateway = $this->makeGateway([SecurityBcSettingsGateway::KEY_AUTH_MODE => 'oauth2']);
$this->assertSame(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2, $gateway->getAuthMode());
}
public function testBuildEntityUrlComposesCompanyAndEntity(): void
{
$gateway = $this->makeGateway([
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com/ODataV4/',
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme GmbH',
]);
$url = $gateway->buildEntityUrl('Integration_Customer_Card');
$this->assertSame("https://bc.example.com/ODataV4/Company('Acme%20GmbH')/Integration_Customer_Card", $url);
}
public function testValidateConfigurationReportsMissingBasicCredentials(): void
{
$gateway = $this->makeGateway([
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com',
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme',
]);
$missing = $gateway->validateConfiguration();
$this->assertNotEmpty($missing);
$this->assertFalse($gateway->isConfigured());
}
public function testIsConfiguredWhenBasicComplete(): void
{
$gateway = $this->makeGateway([
SecurityBcSettingsGateway::KEY_ODATA_BASE_URL => 'https://bc.example.com',
SecurityBcSettingsGateway::KEY_COMPANY_NAME => 'Acme',
SecurityBcSettingsGateway::KEY_BASIC_USER => 'svc',
SecurityBcSettingsGateway::KEY_BASIC_PASSWORD_ENC => 'enc:secret',
]);
$this->assertTrue($gateway->isConfigured());
$this->assertSame('secret', $gateway->getBasicPassword());
}
public function testSetODataBaseUrlRejectsNonHttps(): void
{
$gateway = $this->makeGateway();
$this->assertFalse($gateway->setODataBaseUrl('http://insecure.example.com'));
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use PHPUnit\Framework\TestCase;
class SecurityCheckProcessTest extends TestCase
{
public function testStepsContainSixOrderedSteps(): void
{
$steps = (new SecurityCheckProcess())->steps();
$this->assertCount(6, $steps);
$this->assertSame('beauftragung', $steps[0]['key']);
$this->assertSame(SecurityCheckProcess::STEP_PERFORM_CHECK, $steps[4]['key']);
$this->assertSame('report', $steps[5]['key']);
}
public function testManualStepKeysExcludeTechChecklistStep(): void
{
$keys = (new SecurityCheckProcess())->manualStepKeys();
$this->assertCount(5, $keys);
$this->assertNotContains(SecurityCheckProcess::STEP_PERFORM_CHECK, $keys);
$this->assertContains(SecurityCheckProcess::STEP_INFORMATION, $keys);
}
public function testInfoStepFieldsAreDefined(): void
{
$fields = (new SecurityCheckProcess())->stepFields(SecurityCheckProcess::STEP_INFORMATION);
$this->assertNotEmpty($fields);
$keys = array_column($fields, 'key');
$this->assertContains('technical_contact', $keys);
$this->assertContains('external_systems', $keys);
}
public function testRequiredFieldKeysExcludeOptionalFields(): void
{
$keys = (new SecurityCheckProcess())->requiredFieldKeys(SecurityCheckProcess::STEP_INFORMATION);
$this->assertContains('technical_contact', $keys);
$this->assertContains('deployment', $keys);
$this->assertContains('system_info', $keys);
$this->assertContains('external_systems', $keys);
$this->assertNotContains('special_notes', $keys);
}
public function testSchedulingAndBlockerStepsRequireTwoDatetimeFields(): void
{
$process = new SecurityCheckProcess();
$expected = [
'scheduling' => ['golive_start', 'golive_end'],
'blocker_appointment' => ['blocker_start', 'blocker_end'],
];
foreach ($expected as $stepKey => $expectedKeys) {
$fields = $process->stepFields($stepKey);
$this->assertCount(2, $fields);
foreach ($fields as $field) {
$this->assertSame(SecurityCheckProcess::FIELD_DATETIME, $field['type']);
$this->assertTrue($field['required']);
}
$this->assertSame($expectedKeys, $process->requiredFieldKeys($stepKey));
}
}
public function testTechCheckItemsExtractsOnlyCheckItemsWithKeys(): void
{
$snapshot = ['items' => [
['type' => 'section', 'label' => 'Group'],
['type' => 'check', 'key' => 'tls', 'label' => 'TLS'],
['type' => 'check', 'key' => '', 'label' => 'No key'],
['type' => 'check', 'label' => 'Missing key'],
'not-an-array',
]];
$items = SecurityCheckProcess::techCheckItems($snapshot);
$this->assertCount(1, $items);
$this->assertSame('tls', $items[0]['key']);
}
public function testComputeProgressOpenWhenNothingDone(): void
{
$progress = (new SecurityCheckProcess())->computeProgress([], [], []);
$this->assertSame(5, $progress['manual_total']);
$this->assertSame(0, $progress['tech_total']);
$this->assertSame(0, $progress['done']);
$this->assertSame(0, $progress['percent']);
$this->assertSame(SecurityCheckProcess::STATUS_OPEN, $progress['status']);
$this->assertTrue($progress['step6_done']); // no tech items → trivially done
}
public function testComputeProgressInProgressWhenPartiallyDone(): void
{
$processState = ['beauftragung' => ['done' => true]];
$progress = (new SecurityCheckProcess())->computeProgress($processState, [], []);
$this->assertSame(1, $progress['done']);
$this->assertSame(SecurityCheckProcess::STATUS_IN_PROGRESS, $progress['status']);
}
public function testComputeProgressCompletedWhenAllDone(): void
{
$process = new SecurityCheckProcess();
$snapshot = ['items' => [
['type' => 'check', 'key' => 'a', 'label' => 'A'],
['type' => 'check', 'key' => 'b', 'label' => 'B'],
]];
$processState = [];
foreach ($process->manualStepKeys() as $key) {
$processState[$key] = ['done' => true];
}
$techState = ['a' => ['done' => true], 'b' => ['done' => true]];
$progress = $process->computeProgress($processState, $snapshot, $techState);
$this->assertSame(7, $progress['total']); // 5 manual + 2 tech
$this->assertSame(7, $progress['done']);
$this->assertSame(100, $progress['percent']);
$this->assertTrue($progress['step6_done']);
$this->assertSame(SecurityCheckProcess::STATUS_COMPLETED, $progress['status']);
}
public function testComputeProgressStep6NotDoneWhenSomeTechItemsOpen(): void
{
$snapshot = ['items' => [
['type' => 'check', 'key' => 'a', 'label' => 'A'],
['type' => 'check', 'key' => 'b', 'label' => 'B'],
]];
$techState = ['a' => ['done' => true]];
$progress = (new SecurityCheckProcess())->computeProgress([], $snapshot, $techState);
$this->assertFalse($progress['step6_done']);
$this->assertSame(SecurityCheckProcess::STATUS_IN_PROGRESS, $progress['status']);
}
public function testComputeProgressArchivedOverridesDerivedStatus(): void
{
$progress = (new SecurityCheckProcess())->computeProgress([], [], [], true);
$this->assertSame(SecurityCheckProcess::STATUS_ARCHIVED, $progress['status']);
}
public function testReportPrerequisitesMetWhenAllPrecedingStepsComplete(): void
{
$process = new SecurityCheckProcess();
$snapshot = ['items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]];
$processState = [];
foreach ($process->manualStepKeys() as $key) {
// The report step itself is intentionally left open — it does not gate itself.
$processState[$key] = ['done' => $key !== SecurityCheckProcess::STEP_REPORT];
}
$techState = ['a' => ['done' => true]];
$this->assertTrue($process->reportPrerequisitesMet($processState, $snapshot, $techState));
}
public function testReportPrerequisitesNotMetWhenAManualStepIsOpen(): void
{
$process = new SecurityCheckProcess();
$snapshot = ['items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]];
$processState = [];
foreach ($process->manualStepKeys() as $key) {
$processState[$key] = ['done' => true];
}
$processState['scheduling']['done'] = false; // one preceding step still open
$techState = ['a' => ['done' => true]];
$this->assertFalse($process->reportPrerequisitesMet($processState, $snapshot, $techState));
}
public function testReportPrerequisitesNotMetWhenTechChecklistIncomplete(): void
{
$process = new SecurityCheckProcess();
$snapshot = ['items' => [
['type' => 'check', 'key' => 'a', 'label' => 'A'],
['type' => 'check', 'key' => 'b', 'label' => 'B'],
]];
$processState = [];
foreach ($process->manualStepKeys() as $key) {
$processState[$key] = ['done' => true];
}
$techState = ['a' => ['done' => true]]; // 'b' still open
$this->assertFalse($process->reportPrerequisitesMet($processState, $snapshot, $techState));
}
public function testReportPrerequisitesMetWithEmptyTechChecklist(): void
{
$process = new SecurityCheckProcess();
$processState = [];
foreach ($process->manualStepKeys() as $key) {
$processState[$key] = ['done' => true];
}
// No tech items → checklist trivially complete; only the manual steps gate.
$this->assertTrue($process->reportPrerequisitesMet($processState, [], []));
}
public function testStatusVariantMapping(): void
{
$this->assertSame('neutral', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_OPEN));
$this->assertSame('warning', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_IN_PROGRESS));
$this->assertSame('success', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_COMPLETED));
$this->assertSame('neutral', SecurityCheckProcess::statusVariant(SecurityCheckProcess::STATUS_ARCHIVED));
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckRepository;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityCheckService;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use PHPUnit\Framework\TestCase;
class SecurityCheckServiceTest extends TestCase
{
private const TENANT_ID = 1;
/**
* @return array{0: SecurityCheckService, 1: SecurityCheckRepository, 2: SecurityCheckTemplateService}
*/
private function makeService(?SecurityCheckRepository $repo = null, ?SecurityCheckTemplateService $templates = null): array
{
$repo = $repo ?? $this->createMock(SecurityCheckRepository::class);
$templates = $templates ?? $this->createMock(SecurityCheckTemplateService::class);
$service = new SecurityCheckService($repo, $templates, new SecurityCheckProcess());
return [$service, $repo, $templates];
}
public function testCreateRequiresCustomerDomainAndProduct(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->expects($this->never())->method('insert');
[$service] = $this->makeService($repo);
$result = $service->create(self::TENANT_ID, [], 5);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('debitor_no', $result['errors']);
$this->assertArrayHasKey('domain_no', $result['errors']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateFailsWhenTemplateMissing(): void
{
$templates = $this->createMock(SecurityCheckTemplateService::class);
$templates->method('findByCode')->willReturn(null);
[$service] = $this->makeService(null, $templates);
$result = $service->create(self::TENANT_ID, [
'debitor_no' => 'D1',
'domain_no' => 'DNS1',
'product_code' => 'intranet',
], 5);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateSucceedsAndSnapshotsSchema(): void
{
$templates = $this->createMock(SecurityCheckTemplateService::class);
$templates->method('findByCode')->willReturn(['id' => 7, 'product_name' => 'Intranet', 'active' => 1]);
$templates->method('decodeSchema')->willReturn(['version' => 2, 'items' => [['type' => 'check', 'key' => 'tls', 'label' => 'TLS']]]);
$captured = null;
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('insert')->willReturnCallback(function (int $tenantId, array $data) use (&$captured) {
$captured = $data;
return 42;
});
[$service] = $this->makeService($repo, $templates);
$result = $service->create(self::TENANT_ID, [
'debitor_no' => 'D1',
'debitor_name' => 'Acme',
'domain_no' => 'DNS1',
'domain_url' => 'acme.de',
'product_code' => 'intranet',
'owner_user_id' => 9,
], 5);
$this->assertTrue($result['ok']);
$this->assertSame(42, $result['id']);
$this->assertSame(SecurityCheckProcess::STATUS_OPEN, $captured['status']);
$this->assertSame(7, $captured['template_id']);
$this->assertSame('Intranet', $captured['product_name']);
$snapshot = json_decode((string) $captured['tech_schema_snapshot_json'], true);
$this->assertSame('tls', $snapshot['items'][0]['key']);
}
public function testSaveStateStampsCompletionAndDerivesCompletedStatus(): void
{
$snapshot = json_encode(['version' => 1, 'items' => [['type' => 'check', 'key' => 'tls', 'label' => 'TLS']]]);
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => '{}',
'tech_schema_snapshot_json' => $snapshot,
'tech_state_json' => '{}',
]);
$captured = null;
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
$captured = $data;
return true;
});
$process = new SecurityCheckProcess();
[$service] = $this->makeService($repo);
$step = [];
foreach ($process->manualStepKeys() as $key) {
$step[$key] = ['done' => '1', 'note' => ''];
}
$step[SecurityCheckProcess::STEP_INFORMATION]['fields'] = [
'technical_contact' => 'Jane',
'deployment' => 'Cloud',
'system_info' => 'PHP 8.5',
'external_systems' => 'https://api.example.test',
];
$step['scheduling']['fields'] = [
'golive_start' => '2026-07-01T09:00',
'golive_end' => '2026-07-01T12:00',
];
$step['blocker_appointment']['fields'] = [
'blocker_start' => '2026-07-01T08:00',
'blocker_end' => '2026-07-02T18:00',
];
$input = [
'step' => $step,
'tech' => ['tls' => ['done' => '1', 'note' => 'verified']],
];
$result = $service->saveState(self::TENANT_ID, 1, $input, 77);
$this->assertTrue($result['ok']);
$this->assertSame(SecurityCheckProcess::STATUS_COMPLETED, $captured['status']);
$process_state = json_decode((string) $captured['process_state_json'], true);
$this->assertTrue($process_state['beauftragung']['done']);
$this->assertSame(77, $process_state['beauftragung']['by']);
$this->assertNotEmpty($process_state['beauftragung']['at']);
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
$tech_state = json_decode((string) $captured['tech_state_json'], true);
$this->assertTrue($tech_state['tls']['done']);
$this->assertSame('verified', $tech_state['tls']['note']);
}
public function testSaveStateGatesInformationStepUntilRequiredFieldsFilled(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => '{}',
'tech_schema_snapshot_json' => '{}',
'tech_state_json' => '{}',
]);
$captured = null;
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
$captured = $data;
return true;
});
[$service] = $this->makeService($repo);
$result = $service->saveState(self::TENANT_ID, 1, [
'step' => [SecurityCheckProcess::STEP_INFORMATION => [
'done' => '1',
'note' => '',
'fields' => ['technical_contact' => 'Jane'], // deployment / system_info / external_systems missing
]],
'tech' => [],
], 77);
// Saved (values persisted) but the step is held open + a warning is returned.
$this->assertTrue($result['ok']);
$this->assertArrayHasKey('warning', $result);
$process_state = json_decode((string) $captured['process_state_json'], true);
$this->assertFalse($process_state[SecurityCheckProcess::STEP_INFORMATION]['done']);
$this->assertSame('Jane', $process_state[SecurityCheckProcess::STEP_INFORMATION]['fields']['technical_contact']);
$this->assertNotSame(SecurityCheckProcess::STATUS_COMPLETED, $captured['status']);
}
public function testSaveStateCompletesInformationStepWhenAllRequiredFieldsFilled(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => '{}',
'tech_schema_snapshot_json' => '{}',
'tech_state_json' => '{}',
]);
$captured = null;
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
$captured = $data;
return true;
});
[$service] = $this->makeService($repo);
$result = $service->saveState(self::TENANT_ID, 1, [
'step' => [SecurityCheckProcess::STEP_INFORMATION => [
'done' => '1',
'note' => '',
'fields' => [
'technical_contact' => 'Jane',
'deployment' => 'Cloud',
'system_info' => 'PHP 8.5',
'external_systems' => 'https://api.example.test',
// special_notes intentionally left empty — it is optional
],
]],
'tech' => [],
], 77);
$this->assertTrue($result['ok']);
$this->assertArrayNotHasKey('warning', $result);
$process_state = json_decode((string) $captured['process_state_json'], true);
$this->assertTrue($process_state[SecurityCheckProcess::STEP_INFORMATION]['done']);
$this->assertSame(77, $process_state[SecurityCheckProcess::STEP_INFORMATION]['by']);
}
public function testSaveStateGatesDatetimeStepUntilBothFieldsFilled(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => SecurityCheckProcess::STATUS_OPEN,
'process_state_json' => '{}',
'tech_schema_snapshot_json' => '{}',
'tech_state_json' => '{}',
]);
$captured = null;
$repo->method('updateState')->willReturnCallback(function (int $tenantId, int $id, array $data) use (&$captured) {
$captured = $data;
return true;
});
[$service] = $this->makeService($repo);
// Scheduling needs both datetimes; with only one filled it stays open.
$result = $service->saveState(self::TENANT_ID, 1, [
'step' => ['scheduling' => [
'done' => '1',
'note' => '',
'fields' => ['golive_start' => '2026-07-01T09:00'], // golive_end missing
]],
'tech' => [],
], 77);
$this->assertTrue($result['ok']);
$this->assertArrayHasKey('warning', $result);
$process_state = json_decode((string) $captured['process_state_json'], true);
$this->assertFalse($process_state['scheduling']['done']);
$this->assertSame('2026-07-01T09:00', $process_state['scheduling']['fields']['golive_start']);
}
public function testSaveStateRejectsArchivedCheck(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => SecurityCheckProcess::STATUS_ARCHIVED,
'process_state_json' => '{}',
'tech_schema_snapshot_json' => '{}',
'tech_state_json' => '{}',
]);
$repo->expects($this->never())->method('updateState');
[$service] = $this->makeService($repo);
$result = $service->saveState(self::TENANT_ID, 1, ['step' => [], 'tech' => []], 5);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testSetArchivedUpdatesStatusToArchived(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
'process_state_json' => '{}',
'tech_schema_snapshot_json' => '{}',
'tech_state_json' => '{}',
]);
$capturedStatus = null;
$repo->method('updateStatus')->willReturnCallback(function (int $tenantId, int $id, string $status) use (&$capturedStatus) {
$capturedStatus = $status;
return true;
});
[$service] = $this->makeService($repo);
$result = $service->setArchived(self::TENANT_ID, 1, true, 5);
$this->assertTrue($result['ok']);
$this->assertSame(SecurityCheckProcess::STATUS_ARCHIVED, $capturedStatus);
}
public function testListPagedDelegatesToRepository(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('listPaged')->willReturn(['total' => 3, 'rows' => [['id' => 1]]]);
[$service] = $this->makeService($repo);
$result = $service->listPaged(self::TENANT_ID, ['search' => 'x']);
$this->assertSame(3, $result['total']);
$this->assertCount(1, $result['rows']);
}
public function testListAssignableUsersDelegatesToRepository(): void
{
$repo = $this->createMock(SecurityCheckRepository::class);
$repo->method('listTenantUsers')->willReturn([
['id' => 3, 'display_name' => 'Alice'],
['id' => 7, 'display_name' => 'Bob'],
]);
[$service] = $this->makeService($repo);
$users = $service->listAssignableUsers(self::TENANT_ID);
$this->assertCount(2, $users);
$this->assertSame('Alice', $users[0]['display_name']);
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityCheckTemplateRepository;
use MintyPHP\Module\Security\Service\SecurityCheckTemplateService;
use PHPUnit\Framework\TestCase;
class SecurityCheckTemplateServiceTest extends TestCase
{
private const TENANT_ID = 1;
public function testCreateRejectsEmptyCode(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->expects($this->never())->method('insert');
$service = new SecurityCheckTemplateService($repo);
$result = $service->create(self::TENANT_ID, '', 'Name', 1);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateRejectsInvalidCode(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$service = new SecurityCheckTemplateService($repo);
$result = $service->create(self::TENANT_ID, 'has spaces!', 'Name', 1);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateRejectsDuplicateCode(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('codeExists')->willReturn(true);
$service = new SecurityCheckTemplateService($repo);
$result = $service->create(self::TENANT_ID, 'intranet', 'Intranet', 1);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateSucceeds(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('codeExists')->willReturn(false);
$repo->method('insert')->willReturn(12);
$service = new SecurityCheckTemplateService($repo);
$result = $service->create(self::TENANT_ID, 'intranet', 'Intranet', 1);
$this->assertTrue($result['ok']);
$this->assertSame(12, $result['id']);
}
public function testSaveTechSchemaRejectsMissingTemplate(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('findById')->willReturn(null);
$repo->expects($this->never())->method('updateSchema');
$service = new SecurityCheckTemplateService($repo);
$result = $service->saveTechSchema(self::TENANT_ID, 99, [['type' => 'check', 'label' => 'X']], 1);
$this->assertFalse($result['ok']);
}
public function testSaveTechSchemaSlugifiesKeysBumpsVersionAndKeepsSections(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'version' => 3]);
$capturedJson = null;
$capturedVersion = null;
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json, int $version) use (&$capturedJson, &$capturedVersion) {
$capturedJson = $json;
$capturedVersion = $version;
return true;
});
$service = new SecurityCheckTemplateService($repo);
$result = $service->saveTechSchema(self::TENANT_ID, 1, [
['type' => 'section', 'label' => 'Intranet'],
['type' => 'check', 'label' => 'TLS überall erzwungen', 'hint' => 'check certs'],
], 1);
$this->assertTrue($result['ok']);
$this->assertSame(4, $capturedVersion);
$data = json_decode((string) $capturedJson, true);
$this->assertSame(4, $data['version']);
$this->assertSame('section', $data['items'][0]['type']);
$this->assertSame('check', $data['items'][1]['type']);
$this->assertSame('tls_ueberall_erzwungen', $data['items'][1]['key']);
$this->assertSame('check certs', $data['items'][1]['hint']);
}
public function testSaveTechSchemaPersistsMarkdownAndOmitsEmpty(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
$capturedJson = null;
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json) use (&$capturedJson) {
$capturedJson = $json;
return true;
});
$service = new SecurityCheckTemplateService($repo);
$service->saveTechSchema(self::TENANT_ID, 1, [
['type' => 'check', 'label' => 'TLS', 'markdown' => '**Verify** the certificate chain.'],
['type' => 'check', 'label' => 'Backup', 'markdown' => ' '],
], 1);
$data = json_decode((string) $capturedJson, true);
$this->assertSame('**Verify** the certificate chain.', $data['items'][0]['markdown']);
$this->assertArrayNotHasKey('markdown', $data['items'][1]);
}
public function testSaveTechSchemaDeduplicatesKeys(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
$capturedJson = null;
$repo->method('updateSchema')->willReturnCallback(function (int $t, int $id, string $json) use (&$capturedJson) {
$capturedJson = $json;
return true;
});
$service = new SecurityCheckTemplateService($repo);
$service->saveTechSchema(self::TENANT_ID, 1, [
['type' => 'check', 'label' => 'Backup'],
['type' => 'check', 'label' => 'Backup'],
], 1);
$data = json_decode((string) $capturedJson, true);
$this->assertSame('backup', $data['items'][0]['key']);
$this->assertSame('backup_2', $data['items'][1]['key']);
}
public function testSaveTechSchemaRejectsItemWithoutLabel(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
$repo->expects($this->never())->method('updateSchema');
$service = new SecurityCheckTemplateService($repo);
$result = $service->saveTechSchema(self::TENANT_ID, 1, [['type' => 'check', 'label' => '']], 1);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('schema', $result['errors']);
}
public function testSaveTechSchemaRejectsTooManyItems(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'version' => 1]);
$items = [];
for ($i = 0; $i <= SecurityCheckTemplateService::MAX_ITEMS; $i++) {
$items[] = ['type' => 'check', 'label' => 'Item ' . $i];
}
$service = new SecurityCheckTemplateService($repo);
$result = $service->saveTechSchema(self::TENANT_ID, 1, $items, 1);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('schema', $result['errors']);
}
public function testUpdateMetaRejectsEmptyName(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$repo->expects($this->never())->method('updateMeta');
$service = new SecurityCheckTemplateService($repo);
$result = $service->updateMeta(self::TENANT_ID, 1, '', true, 1);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('product_name', $result['errors']);
}
public function testDecodeSchemaReturnsItems(): void
{
$repo = $this->createMock(SecurityCheckTemplateRepository::class);
$service = new SecurityCheckTemplateService($repo);
$decoded = $service->decodeSchema([
'version' => 5,
'tech_schema_json' => json_encode(['version' => 5, 'items' => [['type' => 'check', 'key' => 'a', 'label' => 'A']]]),
]);
$this->assertSame(5, $decoded['version']);
$this->assertCount(1, $decoded['items']);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityMarkdownGateway;
use PHPUnit\Framework\TestCase;
class SecurityMarkdownGatewayTest extends TestCase
{
public function testRendersBasicMarkdownToHtml(): void
{
$html = (new SecurityMarkdownGateway())->toHtml("**bold** text\n\n- one\n- two");
$this->assertStringContainsString('<strong>bold</strong>', $html);
$this->assertStringContainsString('<li>one</li>', $html);
$this->assertStringContainsString('<li>two</li>', $html);
}
public function testEscapesRawHtmlSoItCannotInjectMarkup(): void
{
$html = (new SecurityMarkdownGateway())->toHtml('<script>alert(1)</script>');
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('&lt;script&gt;', $html);
}
public function testEmptyOrWhitespaceInputReturnsEmptyString(): void
{
$gateway = new SecurityMarkdownGateway();
$this->assertSame('', $gateway->toHtml(''));
$this->assertSame('', $gateway->toHtml(" \n "));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Repository\SecurityTokenRepository;
use MintyPHP\Module\Security\Service\SecurityBcSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityOAuthTokenService;
use PHPUnit\Framework\TestCase;
class SecurityOAuthTokenServiceTest extends TestCase
{
public function testReturnsNullWhenAuthModeIsBasic(): void
{
$settings = $this->createMock(SecurityBcSettingsGateway::class);
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_BASIC);
$repo = $this->createMock(SecurityTokenRepository::class);
$repo->expects($this->never())->method('findValidToken');
$service = new SecurityOAuthTokenService($settings, $repo);
$this->assertNull($service->getAccessToken(1));
}
public function testReturnsNullForInvalidTenant(): void
{
$settings = $this->createMock(SecurityBcSettingsGateway::class);
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2);
$repo = $this->createMock(SecurityTokenRepository::class);
$service = new SecurityOAuthTokenService($settings, $repo);
$this->assertNull($service->getAccessToken(0));
}
public function testReturnsCachedTokenWhenAvailable(): void
{
$settings = $this->createMock(SecurityBcSettingsGateway::class);
$settings->method('getAuthMode')->willReturn(SecurityBcSettingsGateway::AUTH_MODE_OAUTH2);
$repo = $this->createMock(SecurityTokenRepository::class);
$repo->method('findValidToken')->willReturn('cached-bearer-token');
$service = new SecurityOAuthTokenService($settings, $repo);
$this->assertSame('cached-bearer-token', $service->getAccessToken(1));
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Service\Branding\BrandingLogoService;
use PHPUnit\Framework\TestCase;
class SecurityReportPdfServiceTest extends TestCase
{
private function makeService(): SecurityReportPdfService
{
$branding = $this->createMock(BrandingLogoService::class);
$branding->method('hasLogo')->willReturn(false);
// Null tenant-logo service: render falls back to the static brand asset.
return new SecurityReportPdfService(new SecurityCheckProcess(), $branding, null);
}
/**
* @return array<string, mixed>
*/
private function enrichedCheck(): array
{
$process = new SecurityCheckProcess();
$processState = [];
foreach ($process->manualStepKeys() as $key) {
$processState[$key] = ['done' => true, 'at' => '2026-06-18 10:00:00'];
}
$snapshot = ['version' => 1, 'items' => [
['type' => 'section', 'label' => 'Transport'],
['type' => 'check', 'key' => 'tls', 'label' => 'TLS enforced'],
['type' => 'check', 'key' => 'hsts', 'label' => 'HSTS header'],
['type' => 'section', 'label' => 'Application'],
['type' => 'check', 'key' => 'csrf', 'label' => 'CSRF protection'],
]];
$techState = [
'tls' => ['done' => true, 'note' => 'TLS 1.3'],
'hsts' => ['done' => true, 'note' => ''],
'csrf' => ['done' => false, 'note' => 'pending review'],
];
return [
'debitor_name' => 'Acme GmbH',
'debitor_no' => 'D-100',
'domain_url' => 'acme.de',
'domain_no' => 'DNS-1',
'product_name' => 'Intranet',
'product_code' => 'intranet',
'status' => SecurityCheckProcess::STATUS_IN_PROGRESS,
'process_state' => $processState,
'tech_schema_snapshot' => $snapshot,
'tech_state' => $techState,
'progress' => $process->computeProgress($processState, $snapshot, $techState),
];
}
public function testBuildReportContextMapsCheckDataIntoRenderShape(): void
{
$context = $this->makeService()->buildReportContext($this->enrichedCheck());
$this->assertSame('Acme GmbH', $context['customer_name']);
$this->assertSame('D-100', $context['customer_no']);
$this->assertSame('acme.de', $context['domain']);
$this->assertSame('Intranet', $context['product']);
$this->assertNotSame('', $context['title']);
// Summary mirrors the progress math (3 tech items, 2 done).
$this->assertSame(3, $context['summary']['tech_total']);
$this->assertSame(2, $context['summary']['tech_done']);
// Five manual milestones, numbered with the technical step skipped.
$this->assertCount(5, $context['steps']);
$this->assertSame(1, $context['steps'][0]['number']);
$this->assertTrue($context['steps'][0]['done']);
$this->assertSame('2026-06-18 10:00:00', $context['steps'][0]['completed_at']);
// 'report' is step 6 (the tech checklist step 5 is skipped from the milestone list).
$this->assertSame(6, $context['steps'][4]['number']);
// Tech items grouped under their sections, carrying state + notes.
$this->assertCount(2, $context['tech_sections']);
$this->assertSame('Transport', $context['tech_sections'][0]['label']);
$this->assertCount(2, $context['tech_sections'][0]['items']);
$this->assertSame('TLS enforced', $context['tech_sections'][0]['items'][0]['label']);
$this->assertTrue($context['tech_sections'][0]['items'][0]['done']);
$this->assertSame('TLS 1.3', $context['tech_sections'][0]['items'][0]['note']);
$this->assertSame('Application', $context['tech_sections'][1]['label']);
$this->assertFalse($context['tech_sections'][1]['items'][0]['done']);
}
public function testBuildReportContextHandlesEmptyCheckSafely(): void
{
$context = $this->makeService()->buildReportContext([]);
$this->assertSame('', $context['customer_name']);
$this->assertSame('', $context['domain']);
$this->assertSame([], $context['tech_sections']);
$this->assertSame(0, $context['summary']['total']);
$this->assertSame(0, $context['summary']['percent']);
// The fixed process always yields its five manual milestones, all open.
$this->assertCount(5, $context['steps']);
$this->assertFalse($context['steps'][0]['done']);
}
public function testBuildReportFilenameSlugifiesCustomerAndDomain(): void
{
$name = $this->makeService()->buildReportFilename($this->enrichedCheck());
$this->assertSame('security-check-report-acme-gmbh-acme-de.pdf', $name);
}
public function testBuildReportFilenameFallsBackWhenNoIdentifiers(): void
{
$name = $this->makeService()->buildReportFilename([]);
$this->assertSame('security-check-report.pdf', $name);
}
public function testRenderCustomerReportPdfProducesPdfBytes(): void
{
$pdf = $this->makeService()->renderCustomerReportPdf($this->enrichedCheck(), [
'app_name' => 'CoreCore',
]);
$this->assertNotSame('', $pdf);
$this->assertStringStartsWith('%PDF-', $pdf);
}
}

View File

@@ -0,0 +1,547 @@
/* Security module styles. Prefix: security-. */
.security-form-required {
color: var(--app-color-danger, #b42318);
}
/* --- Check workspace: summary + progress --- */
.security-check-summary {
/* Horizontal padding comes from `.app-details-container > section > *`;
only set vertical padding so the summary stays aligned with the rest. */
padding-block: 1rem 0.75rem;
}
.security-check-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem 1.5rem;
margin: 0;
}
.security-check-meta dt {
font-size: 0.8rem;
color: var(--app-text-muted, #667085);
margin-bottom: 0.15rem;
}
.security-check-meta dd {
margin: 0;
font-weight: 500;
}
.security-check-progress {
/* Full-width progress bar directly under the info section. */
padding-block: 0 1.25rem;
}
.security-check-progress-bar {
height: 8px;
border-radius: 999px;
background: var(--app-muted-border-color, #eaecf0);
overflow: hidden;
}
.security-check-progress-fill {
height: 100%;
background: var(--app-notice-success-border-color, #079455);
transition: width 0.2s ease;
}
.security-check-progress-label {
display: block;
margin-top: 0.35rem;
font-size: 0.8rem;
color: var(--app-muted-color, #667085);
}
/* --- Steps: each step is its own separated card --- */
.security-steps {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.security-step {
/* Reset Pico's <article> card chrome (extra margin + shadow); we draw our own border. */
margin: 0;
box-shadow: none;
border: 1px solid var(--app-card-border-color, #e4e7ec);
border-radius: var(--app-border-radius, 8px);
padding: 0.9rem 1rem;
background: var(--app-card-background-color, #fff);
}
.security-step.is-done {
border-color: var(--app-notice-success-border-color, #079455);
background: var(--app-notice-success-background-color, #f6fef9);
}
.security-step-head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
/* Reset Pico's <article> > <header> chrome so it isn't a boxed-off sub-section. */
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.security-step-number {
flex: 0 0 1.75rem;
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
background: var(--app-muted-border-color, #eaecf0);
color: var(--app-muted-color, #475467);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 600;
}
.security-step.is-done .security-step-number {
background: var(--app-notice-success-border-color, #079455);
color: #fff;
}
.security-check-toggle {
display: flex;
align-items: flex-start;
gap: 0.6rem;
cursor: pointer;
margin: 0;
}
.security-step-toggle {
flex: 1 1 auto;
}
.security-step-toggle span {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
/* Match step 5's <h3> headline size so all step card titles are consistent. */
.security-step-toggle strong {
font-size: var(--text-lg);
line-height: var(--leading-tight);
}
.security-check-toggle input[type="checkbox"] {
width: 1.25em;
height: 1.25em;
flex-shrink: 0;
margin: 0.1rem 0 0;
}
.security-completion-meta {
font-size: 0.78rem;
color: var(--app-notice-success-color, #079455);
white-space: nowrap;
margin-left: auto;
}
.security-item-note {
width: 100%;
margin: 0.6rem 0 0;
}
/* Align the per-step note with the indented step fields
(start past the number badge + gap, matching `.security-step-fields`). */
.security-step > .security-item-note {
margin-left: 2.5rem;
width: calc(100% - 2.5rem);
}
.security-step-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem 1rem;
margin: 0.75rem 0 0;
padding-left: 2.5rem;
}
/* Long-form text fields take a full row; compact fields (datetimes) sit side by side. */
.security-step-field {
grid-column: 1 / -1;
}
.security-step-field-datetime {
grid-column: auto;
}
.security-step-field label {
display: block;
font-size: 0.82rem;
color: var(--app-muted-color, #475467);
margin-bottom: 0.2rem;
}
.security-step-field textarea,
.security-step-field input[type="datetime-local"] {
margin: 0;
width: 100%;
}
@media (max-width: 640px) {
.security-step-fields {
grid-template-columns: 1fr;
}
.security-step-field-datetime {
grid-column: 1 / -1;
}
}
/* Hint shown (by the workspace JS) while required info fields are still empty. */
.security-step-gate-hint:not([hidden]) {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.6rem 0 0;
padding-left: 2.5rem;
font-size: 0.8rem;
color: var(--app-notice-warning-color, #ad7122);
}
/* "Generate PDF customer report" action in the final (report) step. Indented to
2.5rem to line up under the step header text, like the fields/notes above. */
.security-report-action {
margin-top: 0.75rem;
padding-left: 2.5rem;
}
.security-report-action > .secondary {
margin-bottom: 0;
}
.security-report-hint {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.4rem 0 0;
font-size: 0.8rem;
}
/* --- Technical checklist --- */
.security-step-tech .security-tech-list {
list-style: none;
margin: 0.75rem 0 0;
/* Indent to 2.5rem so checklist items line up under the step header text
(past the number badge + gap), matching the other steps' fields/notes. */
padding: 0 0 0 2.5rem;
}
.security-step-empty {
margin: 0.75rem 0 0;
padding-left: 2.5rem;
}
.security-tech-section {
font-weight: 600;
margin: 0.9rem 0 0.4rem;
color: var(--app-text, #1d2939);
}
.security-tech-item {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
padding: 0.5rem 0;
border-top: 1px solid var(--app-border-subtle, #f2f4f7);
}
.security-tech-label {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.security-tech-hint {
font-size: 0.78rem;
}
/* Rendered Markdown guidance authored on the template — collapsed by default in
a <details> callout (left accent + info tint) so it clearly reads as the info
for this checkpoint without crowding the form. */
.security-tech-markdown {
flex-basis: 100%;
margin: 0.4rem 0 0.1rem 1.85rem;
padding: 0.4rem 0.75rem;
border-left: 3px solid var(--app-notice-info-border-color, #6b7e99);
border-radius: 0 6px 6px 0;
background: var(--app-notice-info-background-color, #eef1f6);
font-size: 0.85rem;
color: var(--app-color, #1d2939);
}
.security-tech-markdown > summary {
font-weight: 500;
color: var(--app-notice-info-color, #4a5a73);
}
.security-tech-markdown-body > :first-child {
margin-top: 0;
}
.security-tech-markdown-body > :last-child {
margin-bottom: 0;
}
.security-tech-markdown-body :where(ul, ol) {
margin: 0.25rem 0;
padding-left: 1.25rem;
}
.security-tech-markdown-body pre {
padding: 0.5rem 0.75rem;
background: var(--app-card-background-color, #fff);
border-radius: 6px;
overflow-x: auto;
}
.security-tech-item .security-item-note {
flex-basis: 100%;
margin-top: 0.25rem;
}
/* --- Danger zone --- */
.security-danger-zone {
margin-top: 1.5rem;
}
/* Inner wrapper carries the border so it inherits the same 2rem inset
(via `.app-details-container > section > *`) as the steps container. */
.security-danger-zone-inner {
padding: 1rem;
border: 1px solid var(--app-notice-error-border-color, #fda29b);
border-radius: var(--app-border-radius, 8px);
background: var(--app-notice-error-background-color, #fffbfa);
}
.security-danger-zone-inner h3 {
margin: 0 0 0.6rem;
}
.security-danger-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.security-danger-actions form {
margin: 0;
}
.security-danger-actions button {
margin: 0;
}
/* --- Template edit + fields --- */
.security-field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.security-field {
margin-bottom: 0.9rem;
}
.security-field label {
display: block;
margin-bottom: 0.3rem;
font-weight: 500;
}
.security-field input,
.security-field select {
margin: 0;
}
/* The core `input + small` helper rule (app-shell, @layer layout) applies a
negative margin-top that assumes the input keeps its default bottom margin.
We reset that margin to 0 above, so without this the negative pull drags the
hint up into the input. Restore the app's intended helper-text gap. */
.security-field input + small,
.security-field select + small {
margin-top: calc(var(--app-spacing) * 0.25);
}
.security-active-toggle {
margin-top: 0.5rem;
}
/* --- Schema editor --- */
.security-schema-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.75rem 0;
}
.security-schema-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--app-card-border-color, #e4e7ec);
border-radius: 6px;
background: var(--app-card-background-color, #fff);
}
.security-schema-item-section {
background: var(--app-card-sectioning-background-color, #f9fafb);
}
.security-schema-item-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.security-schema-markdown {
margin: 0;
width: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85rem;
}
.security-schema-type {
flex: 0 0 auto;
}
.security-schema-label {
flex: 1 1 40%;
margin: 0;
}
.security-schema-hint {
flex: 1 1 30%;
margin: 0;
}
.security-schema-controls {
display: flex;
gap: 0.2rem;
flex: 0 0 auto;
}
.security-schema-controls button {
margin: 0;
}
.security-schema-toolbar {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.security-schema-toolbar button {
margin: 0;
}
@media (max-width: 640px) {
.security-schema-item-row {
flex-wrap: wrap;
}
.security-schema-label,
.security-schema-hint {
flex-basis: 100%;
}
}
/* --- Creation wizard (check + template create views) ---
The .app-wizard-* primitive is shared in spirit with other modules but its
styles are not in core — they live in each module's own stylesheet. This
module is standalone (requires: []) and never loads another module's CSS, so
without these rules the header collapses to block flow (back arrow stacked
above the title). Kept in sync with the helpdesk wizard. Single-step here, so
the step-indicator rules are intentionally omitted. */
.app-wizard-content {
max-width: 32rem;
}
.app-wizard-header {
display: flex;
align-items: center;
gap: calc(var(--app-spacing) * 0.75);
margin-bottom: calc(var(--app-spacing) * 1.25);
}
.app-wizard-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--app-border-radius);
color: var(--app-muted-color);
text-decoration: none;
transition:
background 0.15s ease,
color 0.15s ease;
}
.app-wizard-back:hover {
background: var(--app-background-muted, #f8f9fa);
color: var(--app-color);
}
.app-wizard-title {
font-size: var(--text-xl, 1.25rem);
font-weight: var(--font-semibold, 600);
margin: 0;
}
.app-wizard-card {
background: var(--app-card-background-color, #fff);
border: 1px solid var(--app-card-border-color, #e5e7eb);
border-radius: calc(var(--app-border-radius) * 2);
padding: calc(var(--app-spacing) * 1.5);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
}
.app-wizard-card-heading {
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
margin: 0 0 0.25rem;
}
.app-wizard-card-description {
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted-color);
margin: 0 0 calc(var(--app-spacing) * 1.25);
}
.app-wizard-field-group {
margin-bottom: calc(var(--app-spacing) * 1.25);
}
.app-wizard-field-group > label {
display: block;
margin-bottom: 0.375rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
}
.app-wizard-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: calc(var(--app-spacing) * 1.5);
padding-top: calc(var(--app-spacing) * 1.25);
border-top: 1px solid var(--app-card-border-color, #e5e7eb);
}
@media (max-width: 576px) {
.app-wizard-card {
padding: var(--app-spacing);
}
}

View File

@@ -0,0 +1,80 @@
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
createListPageModule({
configId: 'security-checks',
moduleId: 'security-checks',
missingGridMessage: 'Security checks grid init failed: Grid.js missing',
initErrorMessage: 'Security checks grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const labels = config.labels || {};
const gridOptions = {
gridjs,
container: '#security-checks-grid',
dataUrl: config.dataUrl || 'security/checks-data',
appBase,
columns: [
{
name: labels.customer || 'Customer',
sort: true,
formatter: (cell) => {
const text = escapeHtml(String(cell?.text || ''));
const url = String(cell?.url || '').trim();
if (text === '') {
return gridjs.html('<span class="text-muted">—</span>');
}
if (url === '') {
return gridjs.html(text);
}
return gridjs.html(`<a href="${escapeHtml(withCurrentListReturn(url))}">${text}</a>`);
},
},
{ name: labels.domain || 'Domain', sort: true },
{ name: labels.product || 'Software product', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
const label = escapeHtml(cell?.label || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
},
},
{ name: labels.owner || 'Owner', sort: true },
{ name: labels.createdAt || 'Created', sort: true },
{ name: 'edit_url', hidden: true },
],
sortColumns: ['debitor_name', 'domain_url', 'product_name', 'status', null, 'created_at', null],
paginationLimit: 20,
language: config.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
{ text: row.debitor_name || '', url: row.edit_url || '' },
row.domain_url || '',
row.product_name || '',
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
row.owner_name || '',
row.created_at || '',
row.edit_url || '',
]),
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[6]?.data || '',
},
};
const listResult = initListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#security-checks-search-input'],
},
});
return listResult?.gridConfig || null;
},
});

View File

@@ -0,0 +1,79 @@
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
createListPageModule({
configId: 'security-templates',
moduleId: 'security-templates',
missingGridMessage: 'Security templates grid init failed: Grid.js missing',
initErrorMessage: 'Security templates grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const labels = config.labels || {};
const gridOptions = {
gridjs,
container: '#security-templates-grid',
dataUrl: config.dataUrl || 'security/templates-data',
appBase,
columns: [
{
name: labels.productName || 'Product',
sort: true,
formatter: (cell) => {
const text = escapeHtml(String(cell?.text || ''));
const url = String(cell?.url || '').trim();
if (text === '') {
return gridjs.html('<span class="text-muted">—</span>');
}
if (url === '') {
return gridjs.html(text);
}
return gridjs.html(`<a href="${escapeHtml(withCurrentListReturn(url))}">${text}</a>`);
},
},
{ name: labels.productCode || 'Code', sort: true },
{ name: labels.itemCount || 'Checklist items', sort: false },
{
name: labels.active || 'Active',
sort: true,
formatter: (cell) => {
const active = cell === true || cell === 'true';
const label = active ? (labels.yes || 'Yes') : (labels.no || 'No');
const variant = active ? 'success' : 'neutral';
return gridjs.html(`<span class="badge" data-variant="${variant}">${escapeHtml(label)}</span>`);
},
},
{ name: labels.updatedAt || 'Updated', sort: true },
{ name: 'edit_url', hidden: true },
],
sortColumns: ['product_name', 'product_code', null, 'active', 'updated_at', null],
paginationLimit: 20,
language: config.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
{ text: row.product_name || row.product_code || '', url: row.edit_url || '' },
row.product_code || '',
String(row.item_count ?? 0),
row.active === true,
row.updated_at || '',
row.edit_url || '',
]),
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
},
};
const listResult = initListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#security-templates-search-input'],
},
});
return listResult?.gridConfig || null;
},
});

View File

@@ -0,0 +1,108 @@
/**
* Security check workspace — light client-side enhancement of the checklist form.
*
* Progress is authoritative on the server (recomputed on save); this only gives
* live visual feedback: toggles the done-styling on a row and updates the
* progress bar width as items are checked. No business logic.
*/
import { resolveHost } from '/js/core/app-dom.js';
const SELECTOR = '[data-app-component="security-check-workspace"]';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveForm = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
return host;
}
return host.querySelector(SELECTOR);
};
export function initSecurityCheckWorkspace(root = document) {
const form = resolveForm(root);
if (!form) {
return EMPTY_API;
}
const section = form.closest('section') || document;
const fill = section.querySelector('.security-check-progress-fill');
const doneToggles = () => Array.from(form.querySelectorAll('input[type="checkbox"][name$="[done]"]'));
// Step gating: a step's done-checkbox can only be ticked once all of its
// required fields are filled. Authoritative gate lives on the server; this
// just mirrors it so the box is disabled until the fields are complete.
const requiredFields = Array.from(form.querySelectorAll('[data-security-required="1"]'));
const readonly = requiredFields.length > 0 && requiredFields.every((field) => field.disabled);
const gateGroups = [];
new Set(requiredFields.map((field) => field.closest('.security-step'))).forEach((step) => {
if (!step) {
return;
}
const doneBox = step.querySelector('input[type="checkbox"][name$="[done]"]');
if (!doneBox) {
return;
}
gateGroups.push({
doneBox,
hint: step.querySelector('[data-security-gate-hint]'),
fields: requiredFields.filter((field) => field.closest('.security-step') === step),
});
});
const enforceGates = () => {
if (readonly) {
return;
}
gateGroups.forEach(({ doneBox, hint, fields }) => {
const met = fields.every((field) => field.value.trim() !== '');
doneBox.disabled = !met;
if (!met && doneBox.checked) {
doneBox.checked = false;
}
if (hint) {
hint.hidden = met;
}
});
};
const listenerController = new AbortController();
const refresh = () => {
const boxes = doneToggles();
const total = boxes.length;
const done = boxes.filter((b) => b.checked).length;
boxes.forEach((box) => {
const row = box.closest('.security-step, .security-tech-item');
row?.classList.toggle('is-done', box.checked);
});
if (fill && total > 0) {
fill.style.width = `${Math.floor((done / total) * 100)}%`;
}
};
form.addEventListener('change', (event) => {
const target = event.target;
if (target instanceof HTMLInputElement && target.type === 'checkbox' && target.name.endsWith('[done]')) {
refresh();
}
}, { signal: listenerController.signal });
if (!readonly && gateGroups.length > 0) {
const onFieldChange = (event) => {
if (event.target instanceof HTMLElement && event.target.matches('[data-security-required="1"]')) {
enforceGates();
refresh();
}
};
form.addEventListener('input', onFieldChange, { signal: listenerController.signal });
form.addEventListener('change', onFieldChange, { signal: listenerController.signal });
}
enforceGates();
refresh();
return {
destroy: () => listenerController.abort(),
};
}

View File

@@ -0,0 +1,194 @@
/**
* Dynamic domain select for the security-check create wizard.
*
* Listens to the core [data-app-lookup] customer picker and loads the customer's
* domains from the Security BC gateway. Falls back to manual entry when the
* customer has no domains.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
const SELECTOR = '#security-domain-group[data-app-component="security-customer-domain-select"]';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveGroup = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
return host;
}
return host.querySelector(SELECTOR);
};
export function initSecurityCustomerDomainSelect(root = document) {
const group = resolveGroup(root);
if (!group) {
return EMPTY_API;
}
const scope = group.closest('form') || document;
const lookupContainer = scope.querySelector('[data-app-lookup]');
const domainSelect = group.querySelector('#security-domain');
const domainNoInput = group.querySelector('#security-domain-no');
const domainUrlInput = group.querySelector('#security-domain-url');
const feedback = group.querySelector('#security-domain-feedback');
const manualBlock = group.querySelector('#security-domain-manual');
const manualNoInput = group.querySelector('#security-domain-manual-no');
const manualUrlInput = group.querySelector('#security-domain-manual-url');
if (!lookupContainer || !domainSelect) {
return EMPTY_API;
}
const domainsUrl = group.dataset.domainsUrl || '';
const textInitial = group.dataset.textInitial || '';
const textLoading = group.dataset.textLoading || '';
const textSelect = group.dataset.textSelect || '';
const textError = group.dataset.textError || '';
const listenerController = new AbortController();
const requestController = new AbortController();
const on = (target, type, handler, options = {}) => {
if (!target || typeof target.addEventListener !== 'function') {
return;
}
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
};
const clearOptions = () => {
while (domainSelect.options.length > 0) {
domainSelect.remove(0);
}
};
const addPlaceholder = (text) => {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = text;
domainSelect.appendChild(opt);
};
const setFeedback = (text, variant) => {
if (!feedback) {
return;
}
feedback.textContent = text;
feedback.hidden = !text;
feedback.dataset.variant = variant || '';
};
const showManual = (show) => {
if (!manualBlock) {
return;
}
manualBlock.hidden = !show;
domainSelect.closest('.app-wizard-domain-select-wrap')?.toggleAttribute('hidden', show);
if (!show && manualNoInput) manualNoInput.value = '';
if (!show && manualUrlInput) manualUrlInput.value = '';
if (show) {
if (domainNoInput) domainNoInput.value = '';
if (domainUrlInput) domainUrlInput.value = '';
}
};
const resetDomain = () => {
clearOptions();
addPlaceholder(textInitial);
domainSelect.disabled = true;
if (domainNoInput) domainNoInput.value = '';
if (domainUrlInput) domainUrlInput.value = '';
setFeedback('', '');
showManual(false);
};
const loadDomains = async (debitorNo) => {
clearOptions();
addPlaceholder(textLoading);
domainSelect.disabled = true;
group.setAttribute('aria-busy', 'true');
setFeedback('', '');
try {
const data = await getJson(`${domainsUrl}?debitor_no=${encodeURIComponent(debitorNo)}`, {
signal: requestController.signal,
});
const items = Array.isArray(data) ? data : [];
clearOptions();
addPlaceholder(textSelect);
if (items.length === 0) {
domainSelect.disabled = true;
showManual(true);
return;
}
showManual(false);
items.forEach((item) => {
const opt = document.createElement('option');
opt.value = item.value || '';
opt.textContent = item.label || item.value || '';
opt.dataset.url = item.url || '';
domainSelect.appendChild(opt);
});
domainSelect.disabled = false;
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
clearOptions();
addPlaceholder(textSelect);
domainSelect.disabled = true;
setFeedback(textError, 'error');
}
} finally {
group.removeAttribute('aria-busy');
}
};
on(domainSelect, 'change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex];
if (domainNoInput) domainNoInput.value = selected?.value || '';
if (domainUrlInput) domainUrlInput.value = selected?.dataset.url || '';
});
if (manualNoInput) {
on(manualNoInput, 'input', () => {
if (domainNoInput) domainNoInput.value = manualNoInput.value.trim();
});
}
if (manualUrlInput) {
on(manualUrlInput, 'input', () => {
if (domainUrlInput) domainUrlInput.value = manualUrlInput.value.trim();
});
}
on(lookupContainer, 'lookup:select', (event) => {
const debitorNo = event.detail?.value || '';
if (debitorNo) {
void loadDomains(debitorNo);
} else {
resetDomain();
}
});
on(lookupContainer, 'lookup:clear', () => {
resetDomain();
});
const initialDebitor = group.dataset.initialDebitor || '';
const initialDomain = group.dataset.initialDomain || '';
if (initialDebitor) {
void loadDomains(initialDebitor).then(() => {
if (initialDomain && !domainSelect.disabled) {
domainSelect.value = initialDomain;
domainSelect.dispatchEvent(new Event('change'));
}
});
} else if (domainNoInput && domainNoInput.value && manualNoInput) {
manualNoInput.value = domainNoInput.value;
}
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

View File

@@ -0,0 +1,80 @@
/**
* Security settings page — toggles the auth-mode sections and runs the
* BC connection test.
*/
import { resolveHost } from '/js/core/app-dom.js';
import { getJson } from '/js/core/app-http.js';
const SELECTOR = '[data-app-component="security-settings"]';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveRoot = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
return host;
}
return host.querySelector(SELECTOR);
};
export function initSecuritySettings(root = document) {
const form = resolveRoot(root);
if (!form) {
return EMPTY_API;
}
const section = form.closest('section') || document;
const listenerController = new AbortController();
const modeSelect = form.querySelector('[data-security-auth-mode]');
const authSections = Array.from(form.querySelectorAll('[data-security-auth-section]'));
const applyMode = () => {
const mode = modeSelect ? modeSelect.value : 'basic';
authSections.forEach((el) => {
el.toggleAttribute('hidden', el.dataset.securityAuthSection !== mode);
});
};
if (modeSelect) {
modeSelect.addEventListener('change', applyMode, { signal: listenerController.signal });
applyMode();
}
const testButton = section.querySelector('[data-security-test-connection]');
const result = section.querySelector('[data-security-test-result]');
if (testButton) {
testButton.addEventListener('click', async () => {
const url = testButton.dataset.url || '';
if (url === '') {
return;
}
testButton.setAttribute('aria-busy', 'true');
testButton.disabled = true;
if (result) {
result.textContent = testButton.dataset.textTesting || 'Testing...';
result.dataset.variant = '';
}
try {
const data = await getJson(url);
if (result) {
const ok = data && data.ok === true;
result.textContent = ok
? (testButton.dataset.textOk || 'Connection successful')
: `${testButton.dataset.textFail || 'Connection failed'}: ${(data && data.error) || ''}`;
result.dataset.variant = ok ? 'success' : 'error';
}
} catch {
if (result) {
result.textContent = testButton.dataset.textFail || 'Connection failed';
result.dataset.variant = 'error';
}
} finally {
testButton.removeAttribute('aria-busy');
testButton.disabled = false;
}
}, { signal: listenerController.signal });
}
return {
destroy: () => listenerController.abort(),
};
}

View File

@@ -0,0 +1,187 @@
/**
* Security check template schema editor.
*
* Edits the per-product technical checklist as an ordered list of items:
* - section: a grouping heading (label only)
* - check: a tracked checkpoint (label + optional hint + optional Markdown guidance)
*
* Serializes the items to the hidden #security-schema-json input on every change
* and on form submit. Item keys are (re)generated server-side on save.
*/
import { resolveHost } from '/js/core/app-dom.js';
import { escapeHtml } from '/js/pages/app-list-utils.js';
const SELECTOR = '#security-schema-editor[data-app-component="security-template-schema-editor"]';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveEditor = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
return host;
}
return host.querySelector(SELECTOR);
};
export function initSecurityTemplateSchemaEditor(root = document) {
const editor = resolveEditor(root);
if (!editor) {
return EMPTY_API;
}
const form = editor.closest('form');
const hidden = form?.querySelector('#security-schema-json');
if (!form || !hidden) {
return EMPTY_API;
}
const labels = {
section: editor.dataset.labelSection || 'Section',
check: editor.dataset.labelCheck || 'Check item',
addSection: editor.dataset.labelAddSection || 'Add section',
addCheck: editor.dataset.labelAddCheck || 'Add check item',
remove: editor.dataset.labelRemove || 'Remove',
moveUp: editor.dataset.labelMoveUp || 'Move up',
moveDown: editor.dataset.labelMoveDown || 'Move down',
phSection: editor.dataset.placeholderSection || 'Section title',
phCheck: editor.dataset.placeholderCheck || 'Check item label',
phHint: editor.dataset.placeholderHint || 'Hint (optional)',
phMarkdown: editor.dataset.placeholderMarkdown || 'Markdown (optional)',
empty: editor.dataset.emptyText || 'No checklist items yet.',
};
const listenerController = new AbortController();
/** @type {{type:string,label:string,hint:string,markdown:string}[]} */
let items = [];
try {
const initial = JSON.parse(document.getElementById('security-schema-initial')?.textContent || '[]');
if (Array.isArray(initial)) {
items = initial.map((it) => ({
type: it && it.type === 'section' ? 'section' : 'check',
label: String((it && it.label) || ''),
hint: String((it && it.hint) || ''),
markdown: String((it && it.markdown) || ''),
}));
}
} catch {
items = [];
}
const serialize = () => {
hidden.value = JSON.stringify(items.map((it) => (
it.type === 'section'
? { type: 'section', label: it.label }
: { type: 'check', label: it.label, hint: it.hint, markdown: it.markdown }
)));
};
const render = () => {
const list = document.createElement('div');
list.className = 'security-schema-items';
if (items.length === 0) {
const empty = document.createElement('p');
empty.className = 'app-muted';
empty.textContent = labels.empty;
list.appendChild(empty);
}
items.forEach((item, index) => {
const row = document.createElement('div');
row.className = `security-schema-item security-schema-item-${item.type}`;
row.dataset.index = String(index);
const topRow = document.createElement('div');
topRow.className = 'security-schema-item-row';
const labelInput = document.createElement('input');
labelInput.type = 'text';
labelInput.className = 'security-schema-label';
labelInput.value = item.label;
labelInput.placeholder = item.type === 'section' ? labels.phSection : labels.phCheck;
labelInput.addEventListener('input', () => { items[index].label = labelInput.value; serialize(); }, { signal: listenerController.signal });
const typeBadge = document.createElement('span');
typeBadge.className = 'security-schema-type badge';
typeBadge.dataset.variant = item.type === 'section' ? 'neutral' : 'info';
typeBadge.textContent = item.type === 'section' ? labels.section : labels.check;
topRow.appendChild(typeBadge);
topRow.appendChild(labelInput);
if (item.type === 'check') {
const hintInput = document.createElement('input');
hintInput.type = 'text';
hintInput.className = 'security-schema-hint';
hintInput.value = item.hint;
hintInput.placeholder = labels.phHint;
hintInput.addEventListener('input', () => { items[index].hint = hintInput.value; serialize(); }, { signal: listenerController.signal });
topRow.appendChild(hintInput);
}
const controls = document.createElement('div');
controls.className = 'security-schema-controls';
controls.innerHTML = `
<button type="button" class="app-icon-button" data-action="up" data-tooltip="${escapeHtml(labels.moveUp)}" aria-label="${escapeHtml(labels.moveUp)}"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="app-icon-button" data-action="down" data-tooltip="${escapeHtml(labels.moveDown)}" aria-label="${escapeHtml(labels.moveDown)}"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="app-icon-button" data-action="remove" data-tooltip="${escapeHtml(labels.remove)}" aria-label="${escapeHtml(labels.remove)}"><i class="bi bi-x-lg"></i></button>
`;
controls.querySelectorAll('button[data-action]').forEach((btn) => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (action === 'remove') {
items.splice(index, 1);
} else if (action === 'up' && index > 0) {
[items[index - 1], items[index]] = [items[index], items[index - 1]];
} else if (action === 'down' && index < items.length - 1) {
[items[index + 1], items[index]] = [items[index], items[index + 1]];
} else {
return;
}
serialize();
render();
}, { signal: listenerController.signal });
});
topRow.appendChild(controls);
row.appendChild(topRow);
if (item.type === 'check') {
const markdownInput = document.createElement('textarea');
markdownInput.className = 'security-schema-markdown';
markdownInput.rows = 2;
markdownInput.value = item.markdown;
markdownInput.placeholder = labels.phMarkdown;
markdownInput.addEventListener('input', () => { items[index].markdown = markdownInput.value; serialize(); }, { signal: listenerController.signal });
row.appendChild(markdownInput);
}
list.appendChild(row);
});
const toolbar = document.createElement('div');
toolbar.className = 'security-schema-toolbar';
const addSection = document.createElement('button');
addSection.type = 'button';
addSection.className = 'secondary outline small';
addSection.innerHTML = `<i class="bi bi-type-h3"></i> ${escapeHtml(labels.addSection)}`;
addSection.addEventListener('click', () => { items.push({ type: 'section', label: '', hint: '', markdown: '' }); serialize(); render(); }, { signal: listenerController.signal });
const addCheck = document.createElement('button');
addCheck.type = 'button';
addCheck.className = 'secondary outline small';
addCheck.innerHTML = `<i class="bi bi-check2-square"></i> ${escapeHtml(labels.addCheck)}`;
addCheck.addEventListener('click', () => { items.push({ type: 'check', label: '', hint: '', markdown: '' }); serialize(); render(); }, { signal: listenerController.signal });
toolbar.appendChild(addCheck);
toolbar.appendChild(addSection);
editor.replaceChildren(list, toolbar);
};
form.addEventListener('submit', serialize, { signal: listenerController.signal });
serialize();
render();
return {
destroy: () => listenerController.abort(),
};
}