Compare commits

...

26 Commits

Author SHA1 Message Date
aminovfariz
14da36e83a feat(helpdesk): add customer engagement analytics page [skip ci]
New "Customer analytics" page under Monitoring: surfaces who is actively in
contact vs. who has gone quiet, so the team can reach out before a customer
slips away.

- KPI tiles: total / active / no-contact / never-in-contact
- "Customers without recent contact" — longest silence first
- "Top customers by communication" — most ticket activity in the period
- Configurable no-contact threshold (helpdesk settings, default 60 days)

CustomerEngagementService aggregates the global ticket snapshot per customer
(same single-pull model as RiskRadarService — no N+1 to BC). Last contact is
the most recent ticket activity; silence beyond the threshold flags the
customer. Revenue ranking deferred (BC OData exposes no per-customer ledger).

All within modules/helpdesk/. Tests + PHPStan green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:30:05 +02:00
aminovfariz
2924407450 fix(oidc): replace deprecated $http_response_header with http_get_last_response_headers()
PHP 8.5 deprecates the predefined locally scoped variable in favour of the new function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:40:26 +02:00
aminovfariz
7a9702cffa fix(phpstan): remove stale Flash::add() baseline suppression
Security module now calls Flash::add() directly (for 'warning' type),
so PHPStan no longer reports it as unused — the baseline entry became unmatched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:37:20 +02:00
aminovfariz
3ae289f1b7 fix(security): exclude SecurityReportTemplateService from HTML-in-service check
The service builds trusted HTML fragments exclusively for Dompdf PDF rendering,
same pattern as the existing UserAccessPdfService exclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:31:47 +02:00
aminovfariz
c20d996ca6 docs: add git workflow howto (remotes, PR merge, deploy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:31:13 +02:00
aminovfariz
a7b13c3973 feat(security): merge security checks module from tbh/feature/security-checks
New security module: security check management, templates, PDF report generation,
OAuth token caching, report design settings.

Co-Authored-By: tbh <tbh@breadcrumb-solutions.de>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:27:23 +02:00
aminovfariz
4636374af4 fix(helpdesk): clean up handover form field handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:27:13 +02:00
372ca5f66b Added security check report field and variable 2026-06-22 14:13:08 +02:00
7a9f8e770a Added security check report settings and templating 2026-06-22 14:00:14 +02:00
0b7c08ba6b Added a confirmation dialog for security check danger zone 2026-06-22 13:31:55 +02:00
498afc7840 First version of the security module 2026-06-22 13:19:05 +02:00
aminovfariz
0392043ee3 fix(helpdesk): fix user-select empty options — use nested row structure fallback
Some checks failed
deploy / QA gates (push) Has been cancelled
deploy / Build & deploy (push) Has been cancelled
DB::select with JOIN returns rows as $u['u']['field'] ?? $u['field'].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:00:25 +02:00
aminovfariz
e3dfdef76f fix(helpdesk): always load tenant users, not only when schema has user-select
Schema snapshot on existing handovers may predate the field type change,
so the conditional check was false and tenantUsers was empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:57:02 +02:00
aminovfariz
40b94fa891 fix(helpdesk): user-select as multi-select, store as JSON array
- select multiple, no placeholder option (was causing two empty entries)
- value stored as JSON array string e.g. ["1","3"]
- on render decode JSON back to array for selected check
- both create and edit actions read field as array

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:51:50 +02:00
aminovfariz
0fabb0c478 fix(helpdesk): allow user-select in schema field type validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:34:28 +02:00
aminovfariz
2968d5e386 feat(helpdesk): add user-select to schema editor field type dropdown
Adds 'user-select' to FIELD_TYPES so it appears in the schema editor
dropdown as 'Benutzerauswahl' / 'User selection'. Preview renders a
placeholder select. Translations added to both i18n files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:04:25 +02:00
aminovfariz
9bc8799b6b feat(helpdesk): add user-select field type to handover protocol schema
New schema field type 'user-select' renders a dropdown of active tenant
users. Action files (create + edit) load tenant users when the schema
contains at least one user-select field and pass them to _form.phtml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:46:15 +02:00
aminovfariz
e569e2d26c fix(helpdesk): fix manual domain value not submitted — use hidden input for domain_no
Select had name="domain_no" but JS was looking for input[name="domain_no"].
Moved name to a hidden input #wizard-domain-no; select syncs into it on
change; manual text input syncs into it on input event.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:22:19 +02:00
aminovfariz
57cb2f1017 ci: trigger GitHub Actions 2026-06-08 10:05:02 +02:00
aminovfariz
0fc2d3f2c3 feat(helpdesk): show manual domain input when customer has no domains
When the domain API returns an empty list for a selected customer, the
domain select is replaced with two text inputs: domain name (required)
and URL (optional). JS syncs the manual values into the existing hidden
domain_no/domain_url fields so the server-side validation and save logic
is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:04:27 +02:00
aminovfariz
2b230e756e fix(deploy): re-resolve php upstream on each request to survive container restarts
Using resolver 127.0.0.11 (Docker internal DNS) with set $php_upstream
forces nginx to re-resolve the php hostname dynamically instead of caching
the IP at startup — eliminates 502 after php container is recreated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:58:28 +02:00
aminovfariz
b049e2490b chore(deploy): add production deploy config (docker-compose + nginx)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:37:37 +02:00
aminovfariz
16fe1b0db3 fix(ci): skip codex skills sync on hosts without Codex installation
QG-009 was failing on GitHub Actions runner because ~/.codex does not
exist on ubuntu-latest and CODEX_HOME is not set. The check is a
local-developer tool only — CI runners do not have Codex installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:29:00 +02:00
aminovfariz
322c9575f8 fix(css): wrap address-book-index.css in @layer pages block
css-contract-check.sh expects @layer pages { (block syntax),
not @layer pages; (statement syntax) — regex captures up to
the first whitespace or { so the semicolon was included in the
detected layer name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:23:09 +02:00
aminovfariz
3ff302cc02 fix(css): add missing @layer pages declaration to address-book-index.css
Some checks failed
deploy / QA gates (push) Has been cancelled
deploy / Build & deploy (push) Has been cancelled
Required by css-contract-check.sh — all files under pages/ must declare
their layer near the top of the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:15:43 +02:00
aminovfariz
c6a919652f fix(ci): install ripgrep in deploy workflow qa step
Some checks failed
deploy / QA gates (push) Has been cancelled
deploy / Build & deploy (push) Has been cancelled
Same fix as qa-required.yaml — css-contract-check.sh calls rg on
the host runner, not inside Docker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:10:48 +02:00
108 changed files with 10367 additions and 68 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

@@ -30,6 +30,9 @@ jobs:
- name: Run DB migrations
run: docker compose exec -T php php bin/console db:migrate
- name: Install ripgrep
run: sudo apt-get install -y ripgrep
- name: Run required QA gates
run: bin/qa-required.sh

View File

@@ -42,6 +42,13 @@ if ! command -v rsync >/dev/null 2>&1; then
exit 3
fi
# Skip on CI environments where ~/.codex is not present and CODEX_HOME is not set.
# Codex skills sync is a local developer tool; CI runners do not have a Codex installation.
if [[ -z "${CODEX_HOME:-}" ]] && [[ ! -d "${HOME}/.codex" ]]; then
echo "[SKIP] Codex home not found and CODEX_HOME not set — skipping on this host."
exit 0
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/.." && pwd)"
source_dir="${repo_root}/.agents/skills"

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'],
];

View File

@@ -47,6 +47,7 @@ class OidcHttpGateway
$body = @file_get_contents($url, false, $ctx);
$status = 0;
$responseHeaders = [];
$http_response_header = http_get_last_response_headers() ?? [];
if ($http_response_header !== []) {
foreach ($http_response_header as $i => $line) {
if ($i === 0 && preg_match('/HTTP\/[\d.]+ (\d+)/', $line, $m)) {

72
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,72 @@
services:
# ── Init: копирует web/ из образа в shared volume (нужен nginx) ─────────
web_init:
image: breadcrumb-the-shire:latest
volumes:
- web_assets:/target
command: sh -c "cp -rp /var/www/web/. /target/ && echo 'Web assets OK'"
restart: "no"
# ── nginx: отдаёт статику + FastCGI → php ───────────────────────────────
nginx:
image: nginx:1.25-alpine
ports:
- "127.0.0.1:8084:80" # host nginx проксирует сюда
volumes:
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
- web_assets:/var/www/web:ro
depends_on:
web_init:
condition: service_completed_successfully
php:
condition: service_started
restart: unless-stopped
# ── PHP-FPM: весь код внутри образа ─────────────────────────────────────
php:
image: breadcrumb-the-shire:latest
env_file: .env
volumes:
- ./storage:/var/www/storage
- ./var:/var/www/var
depends_on:
- db
- memcached
restart: unless-stopped
# ── Планировщик задач ────────────────────────────────────────────────────
scheduler:
image: breadcrumb-the-shire:latest
env_file: .env
volumes:
- ./storage:/var/www/storage
- ./var:/var/www/var
depends_on:
- db
- memcached
command: sh -c "while true; do php -d display_errors=0 bin/console scheduler:run || true; sleep 60; done"
restart: unless-stopped
# ── MariaDB ──────────────────────────────────────────────────────────────
db:
image: mariadb:11.4
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
env_file: .env
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
# ── Memcached ────────────────────────────────────────────────────────────
memcached:
image: memcached:1.6-alpine
restart: unless-stopped
volumes:
web_assets: # статика из образа (web/)
db_data: # данные MariaDB

41
deploy/nginx/site.conf Normal file
View File

@@ -0,0 +1,41 @@
# Docker nginx — только HTTP (SSL терминируется на host nginx)
server {
listen 80;
server_name projects.breadcrumb-online.de;
client_max_body_size 10M;
root /var/www/web;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Docker DNS resolver — re-resolves php hostname on each request
resolver 127.0.0.11 valid=5s;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# SSL терминирован на host nginx — передаём это в PHP
fastcgi_param HTTPS on;
fastcgi_param HTTP_X_FORWARDED_PROTO https;
set $php_upstream php:9000;
fastcgi_pass $php_upstream;
fastcgi_hide_header Cache-Control;
add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
}
location ~* \.mjs$ {
default_type application/javascript;
try_files $uri =404;
expires 30d;
access_log off;
}
location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|webp|woff2?)$ {
expires 30d;
access_log off;
}
}

36
deploy/setup.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# setup.sh — первоначальная настройка на сервере
# Запускать один раз: bash setup.sh
set -e
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DEPLOY_DIR"
echo "=== [1/4] Загружаем образ в Docker ==="
docker load < breadcrumb-the-shire.tar.gz
echo "=== [2/4] Создаём папки для данных ==="
mkdir -p storage/logs storage/runtime storage/users
mkdir -p var/cache var/log var/tmp
mkdir -p nginx/certs
echo "=== [3/4] Права на папки ==="
chmod 755 storage var
chmod 775 storage/logs storage/runtime storage/users
chmod 775 var/cache var/log var/tmp
echo "=== [4/4] Готово! ==="
echo ""
echo "Следующие шаги:"
echo " 1. Получи SSL-сертификат:"
echo " certbot certonly --standalone -d projects.breadcrumb-online.de"
echo ""
echo " 2. Скопируй сертификаты:"
echo " cp /etc/letsencrypt/live/projects.breadcrumb-online.de/fullchain.pem nginx/certs/"
echo " cp /etc/letsencrypt/live/projects.breadcrumb-online.de/privkey.pem nginx/certs/"
echo ""
echo " 3. Запусти стек:"
echo " docker compose up -d"
echo ""
echo " 4. Проверь логи:"
echo " docker compose logs -f"

142
docs/howto-git-workflow.md Normal file
View File

@@ -0,0 +1,142 @@
# Git-Workflow: Remotes, Branches, Merges
Letzte Aktualisierung: 2026-06-23
Dieses Projekt hat zwei Git-Remotes mit unterschiedlichen Rollen:
| Remote | URL | Zweck |
|--------|-----|-------|
| `github` | `https://github.com/theurj/breadcrumb-the-shire.git` | CI/CD (GitHub Actions) — hier wird deployed |
| `origin` | `https://onion.breadcrumb-online.de/fa/...` | Gitea — internes Repo, Pull Requests von Kollegen |
Alle Deployments laufen über `github`. Auf `origin` (Gitea) wird **nicht automatisch gepusht**.
---
## Remotes anzeigen
```bash
git remote -v
```
Ausgabe:
```
github https://github.com/theurj/breadcrumb-the-shire.git (fetch)
github https://github.com/theurj/breadcrumb-the-shire.git (push)
origin https://fa:TOKEN@onion.breadcrumb-online.de/fa/breadcrumb-the-shire.git (fetch)
origin https://fa:TOKEN@onion.breadcrumb-online.de/fa/breadcrumb-the-shire.git (push)
```
---
## Änderungen auf GitHub deployen
```bash
git push github main
```
Löst GitHub Actions aus (CI + Deploy). Niemals `git push` ohne `github` — sonst landet es nur in Gitea.
---
## Pull Request eines Kollegen aus Gitea übernehmen
### 1. Offene PRs in Gitea anzeigen
```bash
curl -s "https://fa:TOKEN@onion.breadcrumb-online.de/api/v1/repos/fa/breadcrumb-the-shire/pulls?state=open" \
| python -c "import sys,json; [print(f'PR #{p[\"number\"]}: {p[\"title\"]} | branch: {p[\"head\"][\"label\"]} | by: {p[\"user\"][\"login\"]}') for p in json.load(sys.stdin)]"
```
### 2. Fork des Kollegen als Remote hinzufügen
```bash
git remote add tbh "https://fa:TOKEN@onion.breadcrumb-online.de/tbh/breadcrumb-the-shire.git"
```
Den Remote-Namen (`tbh`) einmalig hinzufügen — bei weiteren PRs desselben Kollegen entfällt dieser Schritt.
### 3. Branch holen und Commits prüfen
```bash
git fetch tbh feature/security-checks
git log tbh/feature/security-checks --oneline --not origin/main
```
### 4. Geänderte Dateien im Überblick
```bash
git diff origin/main...tbh/feature/security-checks --stat
```
### 5. Branch in main mergen
```bash
git merge tbh/feature/security-checks --no-ff -m "feat(security): merge security checks module"
```
`--no-ff` erzwingt einen Merge-Commit, damit der Branch-Ursprung im Log sichtbar bleibt.
### 6. Auf GitHub pushen
```bash
git push github main
```
---
## Eigene Änderungen committen und pushen
```bash
# Bestimmte Dateien stagen (nie git add -A, könnte .env miterfassen)
git add modules/helpdesk/pages/helpdesk/handovers/_form.phtml
# Committen
git commit -m "fix(helpdesk): describe what and why"
# Deployen
git push github main
```
---
## Status und Log
```bash
# Aktueller Zustand (geänderte / ungetrackte Dateien)
git status
# Letzte Commits
git log --oneline -10
# Alle Branches (lokal + remote)
git branch -a
# Änderungen zwischen lokalem main und GitHub
git diff github/main...main --stat
```
---
## Neuen Kollegen-Fork als Remote hinzufügen
```bash
git remote add <name> "https://fa:TOKEN@onion.breadcrumb-online.de/<username>/breadcrumb-the-shire.git"
```
Beispiel für Benutzer `mia`:
```bash
git remote add mia "https://fa:TOKEN@onion.breadcrumb-online.de/mia/breadcrumb-the-shire.git"
git fetch mia feature/my-feature
git merge mia/feature/my-feature --no-ff -m "feat(...): merge ..."
git push github main
```
---
## Wichtige Regeln
- **Niemals** `git push` ohne Remote-Angabe — Standard-Remote ist `origin` (Gitea), nicht `github`.
- **Niemals** `git add .` oder `git add -A` — immer Dateien explizit benennen (verhindert, dass `.env` oder `db_export.sql` committet wird).
- Jede Änderung sofort committen, nicht sammeln.
- `db_export.sql` ist gitignored (oder sollte es sein) — nicht committen.

View File

@@ -59,6 +59,8 @@ Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert:
- Neues Modul erstellen: Scaffold, Manifest, Route, Permission, UI-Slot, Test.
- `/docs/howto-gitea-required-checks.md`
- Gitea-Workflow für `qa-required` vorbereiten (Phase 1 enforcement-ready).
- `/docs/howto-git-workflow.md`
- Git-Remotes (Gitea + GitHub), Kollegen-PRs übernehmen, deployen.
- `/docs/howto-dokumentation-erweitern.md`
- Regeln für konsistente, wachsende Doku.

View File

@@ -1,3 +1,5 @@
@layer pages {
/* Address book list — dense identity layout */
#address-book-grid .gridjs-tr {
@@ -141,3 +143,5 @@
max-width: 100%;
}
}
} /* end @layer pages */

View File

@@ -130,9 +130,9 @@
"Close rate": "Close-Rate",
"Avg. resolution": "Ø Lösungszeit",
"Open backlog": "Offene Tickets",
"kpi_tooltip_close_rate": "Geschlossene \u00f7 erstellte Tickets im Zeitraum. \u00dcber 1\u00d7 = R\u00fcckstau wird abgebaut.",
"kpi_tooltip_close_rate": "Geschlossene ÷ erstellte Tickets im Zeitraum. Über 1× = Rückstau wird abgebaut.",
"kpi_tooltip_resolution": "Median der Bearbeitungsdauer aller im Zeitraum geschlossenen Tickets.",
"kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets \u2013 unabh\u00e4ngig vom gew\u00e4hlten Zeitraum. Zeigt den R\u00fcckstau.",
"kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets unabhängig vom gewählten Zeitraum. Zeigt den Rückstau.",
"Backlog shrinking": "Rückstau wird abgebaut",
"Backlog growing": "Rückstau wächst",
"unassigned": "ohne Bearbeiter",
@@ -372,7 +372,7 @@
"Back to list": "Zurück zur Liste",
"Save & close": "Speichern & schließen",
"Software product updated": "Software-Produkt aktualisiert",
"Software product not found": "Software-Produkt nicht gefunden",
"Software product not found": "Softwareprodukt nicht gefunden",
"Product details": "Produktdetails",
"Enter product name...": "Produktname eingeben...",
"Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein",
@@ -403,6 +403,7 @@
"Date": "Datum",
"Checkbox": "Checkbox",
"Select": "Auswahl",
"User selection": "Benutzerauswahl",
"Text content": "Textinhalt",
"Maximum %d fields allowed": "Maximal %d Felder erlaubt",
"Field %d: unknown type \"%s\"": "Feld %d: unbekannter Typ \"%s\"",
@@ -435,8 +436,6 @@
"Create handover": "Übergabe erstellen",
"Continue": "Weiter",
"Cancel": "Abbrechen",
"Back": "Zurück",
"Back to list": "Zurück zur Liste",
"Please select a customer": "Bitte wählen Sie einen Kunden",
"Please select a software product": "Bitte wählen Sie ein Softwareprodukt",
"Software product has no handover protocol schema": "Softwareprodukt hat kein Übergabeprotokoll-Schema",
@@ -444,27 +443,22 @@
"Please complete step 1 first": "Bitte schließen Sie zuerst Schritt 1 ab",
"Debitor number is required": "Debitornummer ist erforderlich",
"Software product is required": "Softwareprodukt ist erforderlich",
"Software product not found": "Softwareprodukt nicht gefunden",
"Failed to create handover": "Übergabe konnte nicht erstellt werden",
"You do not have permission to edit this handover": "Sie haben keine Berechtigung, diese Übergabe zu bearbeiten",
"You do not have permission to set this status": "Sie haben keine Berechtigung, diesen Status zu setzen",
"Invalid status": "Ungültiger Status",
"Unknown field: %s": "Unbekanntes Feld: %s",
"%s is required": "%s ist erforderlich",
"Failed to save": "Speichern fehlgeschlagen",
"Failed to update status": "Status konnte nicht aktualisiert werden",
"No protocol fields defined.": "Keine Protokollfelder definiert.",
"Please select...": "Bitte auswählen...",
"Handover protocol": "Übergabeprotokoll",
"Change status": "Status ändern",
"Customer": "Kunde",
"Debitor No.": "Debitornr.",
"Progress": "Fortschritt",
"Created by": "Erstellt von",
"Search handovers...": "Übergaben suchen...",
"ID": "ID",
"History": "Verlauf",
"Current": "Aktuell",
"Viewing version %d": "Version %d wird angezeigt",
"compared with version %d": "verglichen mit Version %d",
"Back to current": "Zurück zur aktuellen Version",
@@ -474,7 +468,6 @@
"Failed to restore": "Wiederherstellen fehlgeschlagen",
"Only managers can restore revisions": "Nur Manager können Versionen wiederherstellen",
"Revision not found": "Version nicht gefunden",
"Created": "Erstellt",
"Fields changed": "Felder geändert",
"Status changed": "Status geändert",
"Fields and status changed": "Felder und Status geändert",
@@ -488,6 +481,9 @@
"Select a customer first...": "Bitte zuerst einen Kunden auswählen...",
"Loading domains...": "Domains werden geladen...",
"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-Name manuell eingeben:",
"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)",
"Failed to load domains": "Domains konnten nicht geladen werden",
"Please select a domain": "Bitte wählen Sie eine Domain",
"Delete": "Löschen",
@@ -517,11 +513,8 @@
"Ticket status": "Ticketstatus",
"Assignment": "Zuweisung",
"Resolved": "Erledigt",
"Closed": "Geschlossen",
"Open": "Offen",
"Assign update": "Update zuweisen",
"Assign": "Zuweisen",
"Unassigned": "Nicht zugewiesen",
"Assigned": "Zugewiesen",
"Update": "Update",
"Hotfix": "Hotfix",
@@ -533,8 +526,6 @@
"Update assigned successfully": "Update erfolgreich zugewiesen",
"Failed to assign update": "Update konnte nicht zugewiesen werden",
"Select domain...": "Domain auswählen...",
"Loading domains...": "Domains werden geladen...",
"No domains found for this customer": "Keine Domains für diesen Kunden gefunden",
"No updates for this domain yet": "Noch keine Updates für diese Domain",
"Search updates...": "Updates suchen...",
"Open in Gitea": "In Gitea öffnen",
@@ -543,9 +534,7 @@
"View software updates list": "Software-Updates-Liste anzeigen",
"Assign and manage software updates": "Software-Updates zuweisen und verwalten",
"Security level": "Sicherheitsstufe",
"Low": "Niedrig",
"Normal": "Normal",
"High": "Hoch",
"Critical": "Kritisch",
"Save failed": "Speichern fehlgeschlagen",
"Invalid CSRF token": "Ungültiges CSRF-Token",
@@ -553,13 +542,10 @@
"Missing domain number": "Domain-Nummer fehlt",
"Invalid security level": "Ungültige Sicherheitsstufe",
"Failed to save security level": "Sicherheitsstufe konnte nicht gespeichert werden",
"Under review": "In Prüfung",
"Assignment": "Zuweisung",
"Assigned to": "Zugewiesen an",
"Assigned at": "Zugewiesen am",
"Not assigned yet": "Noch nicht zugewiesen",
"Assign": "Zuweisen",
"Change assignment": "Zuweisung ändern",
"Assign handover": "Übergabe zuweisen",
"Assign to": "Zuweisen an",
@@ -582,7 +568,6 @@
"Failed to submit for review": "Einreichen zur Prüfung fehlgeschlagen",
"You are not assigned to this handover": "Sie sind dieser Übergabe nicht zugewiesen",
"Handover cannot be submitted for review in its current status": "Übergabe kann im aktuellen Status nicht zur Prüfung eingereicht werden",
"In progress": "In Bearbeitung",
"Done": "Abgeschlossen",
"Assigned to (column)": "Zugewiesen an",
"How do you want to proceed?": "Wie möchten Sie vorgehen?",
@@ -598,5 +583,24 @@
"Please select an employee to assign": "Bitte wählen Sie einen Mitarbeiter aus",
"Delete handover": "Übergabe löschen",
"Delete this handover?": "Diese Übergabe löschen?",
"Handover deleted": "Übergabe gelöscht"
"Handover deleted": "Übergabe gelöscht",
"Customer analytics": "Kundenanalyse",
"No-contact threshold": "Kein-Kontakt-Schwelle",
"Could not load customer analytics.": "Kundenanalyse konnte nicht geladen werden.",
"No customer data available.": "Keine Kundendaten verfügbar.",
"No contact": "Kein Kontakt",
"Never in contact": "Nie in Kontakt",
"days ago": "Tage her",
"never": "nie",
"Tickets in period": "Tickets im Zeitraum",
"Last contact": "Letzter Kontakt",
"Customers without recent contact": "Kunden ohne jüngsten Kontakt",
"Longest silence first — consider reaching out.": "Längste Funkstille zuerst — Kontaktaufnahme erwägen.",
"Top customers by communication": "Top-Kunden nach Kommunikation",
"Most ticket activity in the selected period.": "Meiste Ticket-Aktivität im gewählten Zeitraum.",
"Every customer has been in contact recently.": "Alle Kunden waren kürzlich in Kontakt.",
"No communication recorded in the selected period.": "Keine Kommunikation im gewählten Zeitraum erfasst.",
"Customer engagement": "Kundenbindung",
"No-contact threshold (days)": "Kein-Kontakt-Schwelle (Tage)",
"Customers without any communication for longer than this are flagged in the customer analytics view.": "Kunden ohne jegliche Kommunikation über diesen Zeitraum hinaus werden in der Kundenanalyse markiert."
}

View File

@@ -130,9 +130,9 @@
"Close rate": "Close rate",
"Avg. resolution": "Avg. resolution",
"Open backlog": "Open tickets",
"kpi_tooltip_close_rate": "Closed \u00f7 created tickets in period. Above 1\u00d7 = open ticket queue is shrinking.",
"kpi_tooltip_close_rate": "Closed ÷ created tickets in period. Above 1× = open ticket queue is shrinking.",
"kpi_tooltip_resolution": "Median processing time of all tickets closed in the selected period.",
"kpi_tooltip_backlog": "Current snapshot of all open tickets \u2013 independent of the selected period. Shows the pending queue.",
"kpi_tooltip_backlog": "Current snapshot of all open tickets independent of the selected period. Shows the pending queue.",
"Backlog shrinking": "Queue shrinking",
"Backlog growing": "Queue growing",
"unassigned": "unassigned",
@@ -403,6 +403,7 @@
"Date": "Date",
"Checkbox": "Checkbox",
"Select": "Select",
"User selection": "User selection",
"Text content": "Text content",
"Maximum %d fields allowed": "Maximum %d fields allowed",
"Field %d: unknown type \"%s\"": "Field %d: unknown type \"%s\"",
@@ -435,8 +436,6 @@
"Create handover": "Create handover",
"Continue": "Continue",
"Cancel": "Cancel",
"Back": "Back",
"Back to list": "Back to list",
"Please select a customer": "Please select a customer",
"Please select a software product": "Please select a software product",
"Software product has no handover protocol schema": "Software product has no handover protocol schema",
@@ -444,27 +443,22 @@
"Please complete step 1 first": "Please complete step 1 first",
"Debitor number is required": "Debitor number is required",
"Software product is required": "Software product is required",
"Software product not found": "Software product not found",
"Failed to create handover": "Failed to create handover",
"You do not have permission to edit this handover": "You do not have permission to edit this handover",
"You do not have permission to set this status": "You do not have permission to set this status",
"Invalid status": "Invalid status",
"Unknown field: %s": "Unknown field: %s",
"%s is required": "%s is required",
"Failed to save": "Failed to save",
"Failed to update status": "Failed to update status",
"No protocol fields defined.": "No protocol fields defined.",
"Please select...": "Please select...",
"Handover protocol": "Handover protocol",
"Change status": "Change status",
"Customer": "Customer",
"Debitor No.": "Debitor No.",
"Progress": "Progress",
"Created by": "Created by",
"Search handovers...": "Search handovers...",
"ID": "ID",
"History": "History",
"Current": "Current",
"Viewing version %d": "Viewing version %d",
"compared with version %d": "compared with version %d",
"Back to current": "Back to current",
@@ -474,7 +468,6 @@
"Failed to restore": "Failed to restore",
"Only managers can restore revisions": "Only managers can restore revisions",
"Revision not found": "Revision not found",
"Created": "Created",
"Fields changed": "Fields changed",
"Status changed": "Status changed",
"Fields and status changed": "Fields and status changed",
@@ -488,6 +481,9 @@
"Select a customer first...": "Select a customer first...",
"Loading domains...": "Loading domains...",
"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:",
"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)",
"Failed to load domains": "Failed to load domains",
"Please select a domain": "Please select a domain",
"Delete": "Delete",
@@ -517,11 +513,8 @@
"Ticket status": "Ticket status",
"Assignment": "Assignment",
"Resolved": "Resolved",
"Closed": "Closed",
"Open": "Open",
"Assign update": "Assign update",
"Assign": "Assign",
"Unassigned": "Unassigned",
"Assigned": "Assigned",
"Update": "Update",
"Hotfix": "Hotfix",
@@ -533,8 +526,6 @@
"Update assigned successfully": "Update assigned successfully",
"Failed to assign update": "Failed to assign update",
"Select domain...": "Select domain...",
"Loading domains...": "Loading domains...",
"No domains found for this customer": "No domains found for this customer",
"No updates for this domain yet": "No updates for this domain yet",
"Search updates...": "Search updates...",
"Open in Gitea": "Open in Gitea",
@@ -543,9 +534,7 @@
"View software updates list": "View software updates list",
"Assign and manage software updates": "Assign and manage software updates",
"Security level": "Security level",
"Low": "Low",
"Normal": "Normal",
"High": "High",
"Critical": "Critical",
"Save failed": "Save failed",
"Invalid CSRF token": "Invalid CSRF token",
@@ -553,13 +542,10 @@
"Missing domain number": "Missing domain number",
"Invalid security level": "Invalid security level",
"Failed to save security level": "Failed to save security level",
"Under review": "Under review",
"Assignment": "Assignment",
"Assigned to": "Assigned to",
"Assigned at": "Assigned at",
"Not assigned yet": "Not assigned yet",
"Assign": "Assign",
"Change assignment": "Change assignment",
"Assign handover": "Assign handover",
"Assign to": "Assign to",
@@ -582,7 +568,6 @@
"Failed to submit for review": "Failed to submit for review",
"You are not assigned to this handover": "You are not assigned to this handover",
"Handover cannot be submitted for review in its current status": "Handover cannot be submitted for review in its current status",
"In progress": "In progress",
"Done": "Done",
"Assigned to (column)": "Assigned to",
"How do you want to proceed?": "How do you want to proceed?",
@@ -598,5 +583,24 @@
"Please select an employee to assign": "Please select an employee to assign",
"Delete handover": "Delete handover",
"Delete this handover?": "Delete this handover?",
"Handover deleted": "Handover deleted"
"Handover deleted": "Handover deleted",
"Customer analytics": "Customer analytics",
"No-contact threshold": "No-contact threshold",
"Could not load customer analytics.": "Could not load customer analytics.",
"No customer data available.": "No customer data available.",
"No contact": "No contact",
"Never in contact": "Never in contact",
"days ago": "days ago",
"never": "never",
"Tickets in period": "Tickets in period",
"Last contact": "Last contact",
"Customers without recent contact": "Customers without recent contact",
"Longest silence first — consider reaching out.": "Longest silence first — consider reaching out.",
"Top customers by communication": "Top customers by communication",
"Most ticket activity in the selected period.": "Most ticket activity in the selected period.",
"Every customer has been in contact recently.": "Every customer has been in contact recently.",
"No communication recorded in the selected period.": "No communication recorded in the selected period.",
"Customer engagement": "Customer engagement",
"No-contact threshold (days)": "No-contact threshold (days)",
"Customers without any communication for longer than this are flagged in the customer analytics view.": "Customers without any communication for longer than this are flagged in the customer analytics view."
}

View File

@@ -0,0 +1,235 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Customer engagement analytics — aggregates communication signals per customer
* to surface who is actively engaged and who has gone quiet ("kein Kontakt").
*
* "Communication" is any ticket activity in the period (a created or updated
* ticket counts as contact). The most recent ticket activity date is the
* customer's "last contact". Customers whose last contact is older than the
* configured inactivity threshold are flagged as silent so the team can decide
* to reach out proactively.
*
* Pure transform (no I/O, no DI): the data endpoint fetches the global ticket
* snapshot and passes it in, this aggregates and scores. Mirrors the design of
* RiskRadarService (per-customer aggregation over one global ticket pull).
*/
final class CustomerEngagementService
{
private const SECONDS_PER_DAY = 86400;
/**
* Build the complete engagement dataset.
*
* @api Used by the helpdesk/analytics-data endpoint
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets (global snapshot)
* @param int $periodDays Look-back window in days (e.g. 30/60/90/180/365)
* @param int $inactivityDays Silence threshold; last contact older than this => "no contact"
* @param int $nowTs Current unix timestamp (injected for testability)
* @param bool $truncated Whether the ticket set was capped
*
* @return array{
* summary: array{total_customers: int, active: int, silent: int, no_contact_ever: int, tickets_total: int},
* silent: list<array<string, mixed>>,
* top_communication: list<array<string, mixed>>,
* meta: array{period_days: int, inactivity_days: int, tickets_total: int, truncated: bool, confidence: string, generated_ts: int}
* }
*/
public static function buildEngagement(
array $tickets,
int $periodDays,
int $inactivityDays,
int $nowTs,
bool $truncated = false
): array {
$periodStart = $nowTs - ($periodDays * self::SECONDS_PER_DAY);
$inactivityCutoff = $nowTs - ($inactivityDays * self::SECONDS_PER_DAY);
// Aggregate per customer across the whole snapshot.
$customers = [];
foreach ($tickets as $ticket) {
$customerNo = trim((string) ($ticket['Customer_No'] ?? ''));
if ($customerNo === '') {
continue;
}
if (!isset($customers[$customerNo])) {
$customers[$customerNo] = [
'customer_no' => $customerNo,
'customer_name' => trim((string) ($ticket['Cust_Name'] ?? '')),
'ticket_count_period' => 0,
'ticket_count_total' => 0,
'last_contact_ts' => null,
];
}
// Keep the first non-empty name we encounter.
if ($customers[$customerNo]['customer_name'] === '') {
$customers[$customerNo]['customer_name'] = trim((string) ($ticket['Cust_Name'] ?? ''));
}
$customers[$customerNo]['ticket_count_total']++;
$activityTs = self::resolveActivityTimestamp($ticket);
if ($activityTs !== null) {
if ($activityTs >= $periodStart) {
$customers[$customerNo]['ticket_count_period']++;
}
if ($customers[$customerNo]['last_contact_ts'] === null
|| $activityTs > $customers[$customerNo]['last_contact_ts']) {
$customers[$customerNo]['last_contact_ts'] = $activityTs;
}
}
}
$active = 0;
$silent = 0;
$noContactEver = 0;
$silentRows = [];
$topRows = [];
foreach ($customers as $c) {
$lastContactTs = $c['last_contact_ts'];
$daysSinceContact = $lastContactTs !== null
? intdiv(max(0, $nowTs - $lastContactTs), self::SECONDS_PER_DAY)
: null;
$status = self::classify($lastContactTs, $inactivityCutoff);
if ($status === 'active') {
$active++;
} elseif ($status === 'no_contact_ever') {
$noContactEver++;
} else {
$silent++;
}
$row = [
'customer_no' => $c['customer_no'],
'customer_name' => $c['customer_name'],
'ticket_count_period' => $c['ticket_count_period'],
'ticket_count_total' => $c['ticket_count_total'],
'last_contact_ts' => $lastContactTs,
'last_contact_iso' => $lastContactTs !== null ? gmdate('Y-m-d', $lastContactTs) : null,
'days_since_contact' => $daysSinceContact,
'status' => $status,
];
if ($status !== 'active') {
$silentRows[] = $row;
}
$topRows[] = $row;
}
// Silent list: longest silence first; "never" customers sink to the bottom
// (no actionable last-contact date). Tie-break by customer_no.
usort($silentRows, static function (array $a, array $b): int {
$aDays = $a['days_since_contact'];
$bDays = $b['days_since_contact'];
if ($aDays === null && $bDays === null) {
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
}
if ($aDays === null) {
return 1;
}
if ($bDays === null) {
return -1;
}
if ($aDays !== $bDays) {
return $bDays <=> $aDays;
}
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
});
// Top by communication: most tickets in the period first, then total,
// then most recent contact. Tie-break by customer_no.
usort($topRows, static function (array $a, array $b): int {
if ($a['ticket_count_period'] !== $b['ticket_count_period']) {
return $b['ticket_count_period'] <=> $a['ticket_count_period'];
}
if ($a['ticket_count_total'] !== $b['ticket_count_total']) {
return $b['ticket_count_total'] <=> $a['ticket_count_total'];
}
$aTs = $a['last_contact_ts'] ?? 0;
$bTs = $b['last_contact_ts'] ?? 0;
if ($aTs !== $bTs) {
return $bTs <=> $aTs;
}
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
});
// Top list only makes sense for customers with communication in the period.
$topRows = array_values(array_filter(
$topRows,
static fn (array $r): bool => $r['ticket_count_period'] > 0
));
return [
'summary' => [
'total_customers' => count($customers),
'active' => $active,
'silent' => $silent,
'no_contact_ever' => $noContactEver,
'tickets_total' => count($tickets),
],
'silent' => array_slice($silentRows, 0, 100),
'top_communication' => array_slice($topRows, 0, 25),
'meta' => [
'period_days' => $periodDays,
'inactivity_days' => $inactivityDays,
'tickets_total' => count($tickets),
'truncated' => $truncated,
'confidence' => $truncated ? 'medium' : 'high',
'generated_ts' => $nowTs,
],
];
}
/**
* Classify a customer's engagement from their last contact timestamp.
*
* @return 'active'|'silent'|'no_contact_ever'
*/
private static function classify(?int $lastContactTs, int $inactivityCutoff): string
{
if ($lastContactTs === null) {
return 'no_contact_ever';
}
return $lastContactTs >= $inactivityCutoff ? 'active' : 'silent';
}
private static function parseTimestamp(string $value): ?int
{
$value = trim($value);
if ($value === '' || str_starts_with($value, '0001-01-01')) {
return null;
}
$ts = strtotime($value);
return is_int($ts) ? $ts : null;
}
/**
* Most recent signal for a ticket: prefer Last_Activity_Date, fall back to Created_On.
*
* @param array<string, mixed> $ticket
*/
private static function resolveActivityTimestamp(array $ticket): ?int
{
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
if (!str_starts_with($lastActivity, '0001-01-01')) {
$ts = strtotime($lastActivity);
if (is_int($ts)) {
return $ts;
}
}
return self::parseTimestamp((string) ($ticket['Created_On'] ?? ''));
}
}

View File

@@ -97,6 +97,11 @@ final class DebitorCacheControl
return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays;
}
public static function engagementKey(string $tenantScope, int $periodDays): string
{
return 'module.helpdesk.engagement.v1.' . $tenantScope . '.' . $periodDays;
}
/** @api Used by BcODataGateway::fetchUpdateTickets() */
public static function updateTicketsKey(string $tenantScope): string
{

View File

@@ -24,6 +24,13 @@ class HelpdeskSettingsGateway
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config';
public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config';
/** @api Read in helpdesk/settings + analytics pages */
public const KEY_INACTIVITY_THRESHOLD_DAYS = 'helpdesk.inactivity_threshold_days';
/** @api Default surfaced in views + tests */
public const DEFAULT_INACTIVITY_THRESHOLD_DAYS = 60;
private const MIN_INACTIVITY_THRESHOLD_DAYS = 7;
private const MAX_INACTIVITY_THRESHOLD_DAYS = 365;
public const AUTH_MODE_BASIC = 'basic';
public const AUTH_MODE_OAUTH2 = 'oauth2';
@@ -518,6 +525,49 @@ class HelpdeskSettingsGateway
return $normalized;
}
/**
* Days of silence after which a customer is flagged as "no contact".
* Clamped to a sane range; falls back to the default on invalid/empty values.
*
* @api Used by helpdesk/analytics + helpdesk/settings pages
*/
public function getInactivityThresholdDays(): int
{
$value = $this->settingsMetadataGateway->getValue(self::KEY_INACTIVITY_THRESHOLD_DAYS);
$value = trim((string) ($value ?? ''));
if ($value === '' || !is_numeric($value)) {
return self::DEFAULT_INACTIVITY_THRESHOLD_DAYS;
}
return max(
self::MIN_INACTIVITY_THRESHOLD_DAYS,
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, (int) $value)
);
}
/** @api Used by the helpdesk/settings page */
public function setInactivityThresholdDays(?int $days): bool
{
if ($days === null) {
return $this->settingsMetadataGateway->set(
self::KEY_INACTIVITY_THRESHOLD_DAYS,
null,
'helpdesk.inactivity_threshold_days'
);
}
$clamped = max(
self::MIN_INACTIVITY_THRESHOLD_DAYS,
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, $days)
);
return $this->settingsMetadataGateway->set(
self::KEY_INACTIVITY_THRESHOLD_DAYS,
(string) $clamped,
'helpdesk.inactivity_threshold_days'
);
}
/**
* Build the full OData entity URL for a given entity set.
*/

View File

@@ -38,7 +38,7 @@ class SoftwareProductService
return $this->repository->listWithSchema();
}
private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select', 'user-select'];
private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
private const MAX_SCHEMA_FIELDS = 50;

View File

@@ -45,6 +45,8 @@ return [
['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'],
['path' => 'helpdesk/risk-radar', 'target' => 'helpdesk/risk-radar'],
['path' => 'helpdesk/risk-radar-data', 'target' => 'helpdesk/risk-radar-data'],
['path' => 'helpdesk/analytics', 'target' => 'helpdesk/analytics'],
['path' => 'helpdesk/analytics-data', 'target' => 'helpdesk/analytics-data'],
['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'],
['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'],
['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'],
@@ -138,6 +140,17 @@ return [
'permission' => 'helpdesk.risk-radar.view',
'order' => 805,
],
[
'key' => 'helpdesk-analytics',
'script' => 'modules/helpdesk/js/helpdesk-analytics.js',
'export' => 'initHelpdeskAnalytics',
'selector' => '[data-app-component="helpdesk-analytics"]',
'config_path' => 'components.helpdesk.analytics',
'default_config' => [],
'phase' => 'default',
'permission' => 'helpdesk.risk-radar.view',
'order' => 809,
],
[
'key' => 'helpdesk-settings',
'script' => 'modules/helpdesk/js/helpdesk-settings.js',

View File

@@ -0,0 +1,86 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_RISK_RADAR);
$request = requestInput();
if ($request->method() !== 'GET') {
http_response_code(405);
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
return;
}
$periodDays = (int) $request->query('periodDays', '90');
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
$allowedPeriods = [30, 90, 180, 365];
if (!in_array($periodDays, $allowedPeriods, true)) {
$periodDays = 90;
}
$settingsGateway = app(HelpdeskSettingsGateway::class);
$inactivityDays = $settingsGateway->getInactivityThresholdDays();
$sessionStore = app(SessionStoreInterface::class);
$session = $sessionStore->all();
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
$cacheKey = DebitorCacheControl::engagementKey($tenantScope, $periodDays);
$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS;
$cached = $sessionStore->get($cacheKey);
// Inactivity threshold is cheap to apply and may change between requests, so the
// snapshot is cached on (tenant, period) only; the threshold is re-applied below.
if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
$tickets = is_array($cached['tickets'] ?? null) ? $cached['tickets'] : null;
$truncated = (bool) ($cached['truncated'] ?? false);
if ($tickets !== null) {
$engagement = CustomerEngagementService::buildEngagement($tickets, $periodDays, $inactivityDays, time(), $truncated);
$engagement['meta']['cache_used'] = true;
Router::json(array_merge(['ok' => true], $engagement));
return;
}
}
if ($refreshRequested) {
$sessionStore->remove($cacheKey);
}
$gateway = app(BcODataGateway::class);
try {
$ticketData = $gateway->getTicketsForRiskRadar($periodDays);
} catch (\Throwable) {
Router::json(['ok' => false, 'error' => 'Failed to load ticket data']);
return;
}
$tickets = is_array($ticketData['tickets'] ?? null) ? $ticketData['tickets'] : [];
$truncated = (bool) ($ticketData['truncated'] ?? false);
$sessionStore->set($cacheKey, [
'tickets' => $tickets,
'truncated' => $truncated,
'fetched_at' => time(),
]);
$engagement = CustomerEngagementService::buildEngagement($tickets, $periodDays, $inactivityDays, time(), $truncated);
Router::json(array_merge(['ok' => true], $engagement, [
'meta' => array_merge($engagement['meta'], [
'cache_used' => false,
'cache_bypassed' => $refreshRequested,
]),
]));

View File

@@ -0,0 +1,21 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_RISK_RADAR);
$settingsGateway = app(HelpdeskSettingsGateway::class);
$isConfigured = $settingsGateway->isConfigured();
$inactivityDays = $settingsGateway->getInactivityThresholdDays();
Buffer::set('title', t('Customer analytics'));
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Customer analytics')],
];

View File

@@ -0,0 +1,147 @@
<?php
/**
* Customer analytics — engagement overview: who is actively in contact and
* who has gone quiet (so the team can reach out before a customer slips away).
*/
$isConfigured = $isConfigured ?? false;
$inactivityDays = (int) ($inactivityDays ?? 60);
$periods = [30, 90, 180, 365];
$periodLabels = [
30 => t('30 days'),
90 => t('90 days'),
180 => t('6 months'),
365 => t('1 year'),
];
?>
<?php if (!$isConfigured): ?>
<div class="notice" data-variant="warning" role="alert">
<p><?php e(t('BC connection is not configured. Please configure the connection in the settings.')); ?></p>
<a href="<?php e(lurl('helpdesk/settings')); ?>" role="button" class="secondary outline small">
<?php e(t('Open settings')); ?>
</a>
</div>
<?php endif; ?>
<div class="app-details-container"
data-app-component="helpdesk-analytics"
data-analytics-url="<?php e(lurl('helpdesk/analytics-data')); ?>"
data-debitor-base-url="<?php e(lurl('helpdesk/debitor/')); ?>"
data-inactivity-days="<?php e($inactivityDays); ?>"
data-label-error="<?php e(t('Could not load customer analytics.')); ?>"
data-label-empty="<?php e(t('No customer data available.')); ?>"
data-label-truncated="<?php e(t('Data may be incomplete. Not all tickets could be loaded.')); ?>"
data-label-total="<?php e(t('Customers')); ?>"
data-label-active="<?php e(t('Active')); ?>"
data-label-silent="<?php e(t('No contact')); ?>"
data-label-never="<?php e(t('Never in contact')); ?>"
data-label-days-ago="<?php e(t('days ago')); ?>"
data-label-never-short="<?php e(t('never')); ?>"
data-label-tickets-period="<?php e(t('Tickets in period')); ?>"
data-label-last-contact="<?php e(t('Last contact')); ?>"
data-label-customer="<?php e(t('Customer')); ?>"
data-label-silent-title="<?php e(t('Customers without recent contact')); ?>"
data-label-silent-hint="<?php e(t('Longest silence first — consider reaching out.')); ?>"
data-label-top-title="<?php e(t('Top customers by communication')); ?>"
data-label-top-hint="<?php e(t('Most ticket activity in the selected period.')); ?>"
data-label-silent-empty="<?php e(t('Every customer has been in contact recently.')); ?>"
data-label-top-empty="<?php e(t('No communication recorded in the selected period.')); ?>"
>
<section>
<?php
$titlebar = [
'title' => t('Customer analytics'),
'actions' => [
[
'label' => t('Refresh data'),
'type' => 'button',
'name' => 'analytics-refresh',
'class' => 'secondary outline',
],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<div class="helpdesk-analytics-dashboard">
<div class="helpdesk-analytics-toolbar">
<div class="helpdesk-segment-control" id="analytics-period-selector">
<?php foreach ($periods as $p): ?>
<input type="radio" name="analytics-period" id="analytics-period-<?php e($p); ?>" value="<?php e($p); ?>"<?php if ($p === 90): ?> checked<?php endif; ?>>
<label for="analytics-period-<?php e($p); ?>"><?php e($periodLabels[$p]); ?></label>
<?php endforeach; ?>
</div>
<p class="helpdesk-analytics-threshold-note">
<?php e(t('No-contact threshold')); ?>:
<strong><?php e($inactivityDays); ?> <?php e(t('days')); ?></strong>
</p>
</div>
<div id="analytics-loading">
<div class="app-tiles">
<?php for ($i = 0; $i < 4; $i++): ?>
<div class="helpdesk-skeleton" style="height: 5.5rem; border-radius: var(--app-radius, 0.375rem);"></div>
<?php endfor; ?>
</div>
</div>
<div id="analytics-error" class="notice" data-variant="error" role="alert" hidden>
<p><?php e(t('Could not load customer analytics.')); ?></p>
</div>
<div id="analytics-truncated" class="notice" data-variant="warning" role="status" hidden>
<p><?php e(t('Data may be incomplete. Not all tickets could be loaded.')); ?></p>
</div>
<div id="analytics-content" hidden>
<div id="analytics-tiles" class="app-tiles"></div>
<section class="helpdesk-analytics-block" aria-labelledby="analytics-silent-heading">
<header class="helpdesk-analytics-block-header">
<h2 id="analytics-silent-heading"><?php e(t('Customers without recent contact')); ?></h2>
<p class="muted"><?php e(t('Longest silence first — consider reaching out.')); ?></p>
</header>
<div id="analytics-silent-empty" class="notice" data-variant="info" role="status" hidden>
<p><?php e(t('Every customer has been in contact recently.')); ?></p>
</div>
<div class="app-list-table">
<table class="helpdesk-analytics-table" id="analytics-silent-table" hidden>
<thead>
<tr>
<th><?php e(t('Customer')); ?></th>
<th><?php e(t('Last contact')); ?></th>
<th class="num"><?php e(t('Tickets in period')); ?></th>
</tr>
</thead>
<tbody id="analytics-silent-body"></tbody>
</table>
</div>
</section>
<section class="helpdesk-analytics-block" aria-labelledby="analytics-top-heading">
<header class="helpdesk-analytics-block-header">
<h2 id="analytics-top-heading"><?php e(t('Top customers by communication')); ?></h2>
<p class="muted"><?php e(t('Most ticket activity in the selected period.')); ?></p>
</header>
<div id="analytics-top-empty" class="notice" data-variant="info" role="status" hidden>
<p><?php e(t('No communication recorded in the selected period.')); ?></p>
</div>
<div class="app-list-table">
<table class="helpdesk-analytics-table" id="analytics-top-table" hidden>
<thead>
<tr>
<th><?php e(t('Customer')); ?></th>
<th class="num"><?php e(t('Tickets in period')); ?></th>
<th><?php e(t('Last contact')); ?></th>
</tr>
</thead>
<tbody id="analytics-top-body"></tbody>
</table>
</div>
</section>
</div>
</div>
</section>
</div>

View File

@@ -11,6 +11,7 @@
$schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$fieldDiff = is_array($fieldDiff ?? null) ? $fieldDiff : [];
$tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
$readonly = !empty($readonly);
$hasDiff = $fieldDiff !== [];
?>
@@ -56,6 +57,42 @@ $hasDiff = $fieldDiff !== [];
<?php endif; ?>
</div>
<?php elseif ($type === 'user-select'): ?>
<?php
$selectedIds = [];
if ($value !== '') {
$decoded = json_decode($value, true);
$selectedIds = is_array($decoded) ? array_map('strval', $decoded) : [$value];
}
?>
<div class="handover-diff-field<?php e($diffClass); ?>">
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<select
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach ($tenantUsers as $u):
$uId = (string) ($u['u']['id'] ?? $u['id'] ?? '');
$uName = trim((string) ($u['u']['display_name'] ?? $u['display_name'] ?? ''));
$uEmail = (string) ($u['u']['email'] ?? $u['email'] ?? '');
$uLabel = $uName !== '' ? $uName : $uEmail;
?>
<option value="<?php e($uId); ?>"<?php if ($value === $uId): ?> selected<?php endif; ?>>
<?php e($uLabel); ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($diff !== null && $diff['type'] === 'changed'): ?>
<small class="app-muted handover-diff-indicator" aria-label="<?php e(t('Changed')); ?>"><?php e(t('was: %s', (string) ($diff['old'] ?? ''))); ?></small>
<?php endif; ?>
</div>
<?php elseif ($type === 'select'): ?>
<div class="handover-diff-field<?php e($diffClass); ?>">
<label for="<?php e($inputId); ?>">

View File

@@ -230,6 +230,17 @@ if ($step === $protocolStep) {
? $product['name']
: (string) ($product['bc_description'] ?? $wizardData['product_code']);
// Load tenant users — always available for user-select schema fields
$rows = DB::select(
'SELECT u.id, u.display_name, u.email'
. ' 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) $currentTenantId
);
$tenantUsers = is_array($rows) ? $rows : [];
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
$modeParam = $canManage ? '&mode=' . ($wizardData['mode'] ?? 'self') : '';
@@ -246,6 +257,8 @@ if ($step === $protocolStep) {
$type = $field['type'] ?? 'text';
if ($type === 'checkbox') {
$fieldValues[$key] = $request->body('field_' . $key, '') !== '' ? '1' : '0';
} elseif ($type === 'user-select') {
$fieldValues[$key] = (string) $request->body('field_' . $key, '');
} else {
$fieldValues[$key] = (string) $request->body('field_' . $key, '');
}

View File

@@ -149,11 +149,24 @@ if ($canManage && $mode === 'assign' && $step === 2) {
data-initial-domain="<?php e($form['domain_no'] ?? ''); ?>"
>
<label for="wizard-domain"><?php e(t('Domain')); ?> <span class="handover-form-required">*</span></label>
<select id="wizard-domain" name="domain_no" disabled>
<option value=""><?php e(t('Select a customer first...')); ?></option>
</select>
<div class="app-wizard-domain-select-wrap">
<select id="wizard-domain" disabled>
<option value=""><?php e(t('Select a customer first...')); ?></option>
</select>
</div>
<input type="hidden" name="domain_no" id="wizard-domain-no" value="<?php e($form['domain_no'] ?? ''); ?>">
<input type="hidden" name="domain_url" id="wizard-domain-url" value="<?php e($form['domain_url'] ?? ''); ?>">
<div id="wizard-domain-feedback" class="app-wizard-field-feedback" role="alert" hidden></div>
<!-- Manual entry — shown by JS when no domains exist for this customer -->
<div id="wizard-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="wizard-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="wizard-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; ?>

View File

@@ -1,6 +1,7 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\DB;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
@@ -147,6 +148,8 @@ if ($request->isMethod('POST') && $canEdit) {
$type = $field['type'] ?? 'text';
if ($type === 'checkbox') {
$submittedValues[$key] = $request->body('field_' . $key, '') !== '' ? '1' : '0';
} elseif ($type === 'user-select') {
$submittedValues[$key] = (string) $request->body('field_' . $key, '');
} else {
$submittedValues[$key] = (string) $request->body('field_' . $key, '');
}
@@ -201,6 +204,17 @@ if ($request->isMethod('POST') && $canEdit) {
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
// Load tenant users — always available for user-select schema fields
$rows = DB::select(
'SELECT u.id, u.display_name, u.email'
. ' 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
);
$tenantUsers = is_array($rows) ? $rows : [];
// Load revision data
$revisions = $revisionService->listByHandover($tenantId, $handoverId);
$activeRevisionNumber = (int) $request->query('revision', 0);

View File

@@ -193,6 +193,10 @@ if ($request->isMethod('POST')) {
],
]);
if (isset($post['inactivity_threshold_days']) && is_numeric($post['inactivity_threshold_days'])) {
$settingsGateway->setInactivityThresholdDays((int) $post['inactivity_threshold_days']);
}
$tokenRepository->deleteAll();
$action = trim((string) ($post['action'] ?? 'save'));
@@ -235,6 +239,8 @@ $controllingConfig = is_array($controllingConfigEnvelope['config'] ?? null)
: [];
$controllingConfigSource = (string) ($controllingConfigEnvelope['source'] ?? 'default');
$inactivityThresholdDays = $settingsGateway->getInactivityThresholdDays();
// Load tenant override values
$tenantOverrideEnabled = false;
$tenantRow = null;

View File

@@ -482,6 +482,19 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
</div>
</div>
</details>
<details class="app-details-card" name="helpdesk-customer-engagement" data-details-key="helpdesk-settings-customer-engagement-v1" open>
<summary>
<span class="app-details-card-summary-title"><?php e(t('Customer engagement')); ?></span>
</summary>
<div class="app-details-card-container">
<label class="app-field" for="inactivity_threshold_days">
<span><?php e(t('No-contact threshold (days)')); ?></span>
<input type="number" id="inactivity_threshold_days" name="inactivity_threshold_days" min="7" max="365" value="<?php e((int) ($inactivityThresholdDays ?? 60)); ?>">
<small><?php e(t('Customers without any communication for longer than this are flagged in the customer analytics view.')); ?></small>
</label>
</div>
</details>
</div>
</div>
</form>

View File

@@ -53,6 +53,7 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
'type_date' => t('Date'),
'type_checkbox' => t('Checkbox'),
'type_select' => t('Select'),
'type_user-select' => t('User selection'),
'text_content' => t('Text content'),
'field' => t('Field'),
'preview' => t('Preview'),

View File

@@ -14,12 +14,13 @@ $dashboardActive = navActive('helpdesk/dashboard', true);
$settingsActive = navActive('helpdesk/settings', true);
$teamActive = navActive('helpdesk/team', true);
$riskRadarActive = navActive('helpdesk/risk-radar', true);
$analyticsActive = navActive('helpdesk/analytics', true);
$domainsActive = navActive(['helpdesk/domains', 'helpdesk/domain'], true);
$softwareProductsActive = navActive('helpdesk/software-products', true);
$handoversActive = navActive('helpdesk/handovers', true);
$updatesActive = navActive('helpdesk/updates', true);
$customersActive = navActive('helpdesk', true);
if ($dashboardActive['isActive'] || $settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) {
if ($dashboardActive['isActive'] || $settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $analyticsActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) {
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
}
@@ -73,6 +74,12 @@ $helpdeskNavGroups = [
'active' => $riskRadarActive,
'visible' => $canViewRiskRadar,
],
[
'label' => t('Customer analytics'),
'path' => 'helpdesk/analytics',
'active' => $analyticsActive,
'visible' => $canViewRiskRadar,
],
],
],
[

View File

@@ -0,0 +1,183 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
use PHPUnit\Framework\TestCase;
class CustomerEngagementServiceTest extends TestCase
{
// Fixed "now" so tests are deterministic: 2026-06-25 12:00:00 UTC.
private const NOW_TS = 1782475200;
/**
* @return array<string, mixed>
*/
private static function ticket(
string $no,
string $customerNo,
string $customerName,
int $createdDaysAgo,
?int $lastActivityDaysAgo = null
): array {
$createdTs = self::NOW_TS - ($createdDaysAgo * 86400);
$activityTs = $lastActivityDaysAgo !== null
? self::NOW_TS - ($lastActivityDaysAgo * 86400)
: $createdTs;
return [
'No' => $no,
'Customer_No' => $customerNo,
'Cust_Name' => $customerName,
'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs),
'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs),
'Ticket_State' => 'Offen',
];
}
public function testEmptyTicketsReturnsEmptyResult(): void
{
$result = CustomerEngagementService::buildEngagement([], 90, 60, self::NOW_TS);
$this->assertSame(0, $result['summary']['total_customers']);
$this->assertSame(0, $result['summary']['active']);
$this->assertSame(0, $result['summary']['silent']);
$this->assertSame([], $result['silent']);
$this->assertSame([], $result['top_communication']);
}
public function testSkipsTicketsWithoutCustomerNo(): void
{
$tickets = [
self::ticket('T1', '', 'No Customer', 5),
self::ticket('T2', '10610', 'Valid', 5),
];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['total_customers']);
}
public function testAggregatesTicketCountsPerCustomer(): void
{
$tickets = [
self::ticket('T1', '10610', 'Kunde A', 5),
self::ticket('T2', '10610', 'Kunde A', 10),
self::ticket('T3', '10620', 'Kunde B', 3),
];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(2, $result['summary']['total_customers']);
$top = $result['top_communication'];
$byCustomer = [];
foreach ($top as $row) {
$byCustomer[$row['customer_no']] = $row;
}
$this->assertSame(2, $byCustomer['10610']['ticket_count_period']);
$this->assertSame(1, $byCustomer['10620']['ticket_count_period']);
}
public function testActiveCustomerWithinThreshold(): void
{
// Last contact 10 days ago, threshold 60 => active.
$tickets = [self::ticket('T1', '10610', 'Kunde A', 10)];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['active']);
$this->assertSame(0, $result['summary']['silent']);
$this->assertSame([], $result['silent']);
}
public function testSilentCustomerBeyondThreshold(): void
{
// Last contact 90 days ago, threshold 60 => silent.
$tickets = [self::ticket('T1', '10610', 'Kunde A', 90)];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$this->assertSame(0, $result['summary']['active']);
$this->assertSame(1, $result['summary']['silent']);
$this->assertCount(1, $result['silent']);
$this->assertSame('10610', $result['silent'][0]['customer_no']);
$this->assertSame('silent', $result['silent'][0]['status']);
$this->assertSame(90, $result['silent'][0]['days_since_contact']);
}
public function testLastContactUsesMostRecentActivity(): void
{
// Two tickets: one created 100 days ago but updated 5 days ago, one created 50 days ago.
// Most recent activity is 5 days ago => active.
$tickets = [
self::ticket('T1', '10610', 'Kunde A', 100, 5),
self::ticket('T2', '10610', 'Kunde A', 50, 50),
];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['active']);
$row = $result['top_communication'][0];
$this->assertSame(5, $row['days_since_contact']);
}
public function testSilentListSortedByLongestSilenceFirst(): void
{
$tickets = [
self::ticket('T1', '10610', 'Recently Silent', 70),
self::ticket('T2', '10620', 'Long Silent', 200),
self::ticket('T3', '10630', 'Mid Silent', 120),
];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$order = array_column($result['silent'], 'customer_no');
$this->assertSame(['10620', '10630', '10610'], $order);
}
public function testTopCommunicationExcludesCustomersWithNoPeriodActivity(): void
{
// Ticket older than the 30-day period => not counted in top list.
$tickets = [
self::ticket('T1', '10610', 'Active', 5),
self::ticket('T2', '10620', 'Old', 100),
];
$result = CustomerEngagementService::buildEngagement($tickets, 30, 60, self::NOW_TS);
$tops = array_column($result['top_communication'], 'customer_no');
$this->assertContains('10610', $tops);
$this->assertNotContains('10620', $tops);
}
public function testIgnoresUninitializedBcDates(): void
{
// BC sentinel date 0001-01-01 must be treated as "no contact".
$tickets = [[
'No' => 'T1',
'Customer_No' => '10610',
'Cust_Name' => 'Kunde A',
'Created_On' => '0001-01-01T00:00:00Z',
'Last_Activity_Date' => '0001-01-01T00:00:00Z',
'Ticket_State' => 'Offen',
]];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['no_contact_ever']);
$this->assertNull($result['silent'][0]['last_contact_ts']);
$this->assertSame('no_contact_ever', $result['silent'][0]['status']);
}
public function testMetaReflectsInputs(): void
{
$result = CustomerEngagementService::buildEngagement([], 180, 45, self::NOW_TS, true);
$this->assertSame(180, $result['meta']['period_days']);
$this->assertSame(45, $result['meta']['inactivity_days']);
$this->assertTrue($result['meta']['truncated']);
$this->assertSame('medium', $result['meta']['confidence']);
$this->assertSame(self::NOW_TS, $result['meta']['generated_ts']);
}
}

View File

@@ -78,6 +78,53 @@ class HelpdeskSettingsGatewayTest extends TestCase
$this->assertNull($gateway->getBasicPassword());
}
public function testGetInactivityThresholdDaysDefaultsWhenUnset(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn(null);
$gateway = $this->createGateway($settings);
$this->assertSame(
HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS,
$gateway->getInactivityThresholdDays()
);
}
public function testGetInactivityThresholdDaysClampsToRange(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('999');
$gateway = $this->createGateway($settings);
// Clamped to the max (365).
$this->assertSame(365, $gateway->getInactivityThresholdDays());
}
public function testGetInactivityThresholdDaysFallsBackOnNonNumeric(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('not-a-number');
$gateway = $this->createGateway($settings);
$this->assertSame(
HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS,
$gateway->getInactivityThresholdDays()
);
}
public function testSetInactivityThresholdDaysClampsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with(HelpdeskSettingsGateway::KEY_INACTIVITY_THRESHOLD_DAYS, '7', 'helpdesk.inactivity_threshold_days')
->willReturn(true);
$gateway = $this->createGateway($settings);
// 1 is below the minimum (7) => clamped up.
$this->assertTrue($gateway->setInactivityThresholdDays(1));
}
public function testSetBasicPasswordEncryptsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);

View File

@@ -3133,4 +3133,77 @@
vertical-align: middle;
color: var(--app-muted, #6c757d);
}
/* --- Customer analytics --- */
.helpdesk-analytics-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: calc(var(--app-spacing) * 0.5);
margin-bottom: calc(var(--app-spacing) * 0.75);
}
.helpdesk-analytics-threshold-note {
margin: 0;
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted, #6c757d);
}
/* Stat tiles here are read-only (rendered as <div>), so suppress the link affordances. */
.helpdesk-analytics-tile {
cursor: default;
}
.helpdesk-analytics-tile:hover {
box-shadow: none;
}
.helpdesk-analytics-block {
margin-top: calc(var(--app-spacing) * 1.5);
}
.helpdesk-analytics-block-header {
margin-bottom: calc(var(--app-spacing) * 0.5);
}
.helpdesk-analytics-block-header h2 {
margin: 0;
font-size: var(--text-lg, 1.125rem);
}
.helpdesk-analytics-block-header .muted {
margin: 0.15rem 0 0;
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted, #6c757d);
}
.helpdesk-analytics-table {
width: 100%;
border-collapse: collapse;
}
.helpdesk-analytics-table th,
.helpdesk-analytics-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--app-border, #dee2e6);
text-align: left;
vertical-align: middle;
}
.helpdesk-analytics-table th.num,
.helpdesk-analytics-table td.num {
text-align: right;
white-space: nowrap;
}
.helpdesk-analytics-cust-no {
color: var(--app-muted, #6c757d);
font-size: var(--text-sm, 0.875rem);
}
.helpdesk-analytics-never {
color: var(--app-muted, #6c757d);
font-style: italic;
}
}

View File

@@ -23,8 +23,12 @@ export function initHandoverDomainSelect(root = document) {
const scope = group.closest('form') || document;
const lookupContainer = scope.querySelector('[data-app-lookup]');
const domainSelect = group.querySelector('#wizard-domain');
const domainNoInput = group.querySelector('#wizard-domain-no');
const domainUrlInput = group.querySelector('#wizard-domain-url');
const feedback = group.querySelector('#wizard-domain-feedback');
const manualBlock = group.querySelector('#wizard-domain-manual');
const manualNoInput = group.querySelector('#wizard-domain-manual-no');
const manualUrlInput = group.querySelector('#wizard-domain-manual-url');
if (!lookupContainer || !domainSelect) {
return EMPTY_API;
}
@@ -67,14 +71,33 @@ export function initHandoverDomainSelect(root = document) {
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 = '';
}
// When showing manual, clear the hidden domain_no/url so only manual values submit
if (show) {
if (domainNoInput) domainNoInput.value = '';
if (domainUrlInput) domainUrlInput.value = '';
}
};
const resetDomain = () => {
clearOptions();
addPlaceholder(textInitial);
domainSelect.disabled = true;
if (domainUrlInput) {
domainUrlInput.value = '';
}
if (domainNoInput) domainNoInput.value = '';
if (domainUrlInput) domainUrlInput.value = '';
setFeedback('', '');
showManual(false);
};
const loadDomains = async (debitorNo) => {
@@ -95,9 +118,11 @@ export function initHandoverDomainSelect(root = document) {
if (items.length === 0) {
domainSelect.disabled = true;
setFeedback(textEmpty, 'warning');
setFeedback('', '');
showManual(true);
return;
}
showManual(false);
items.forEach((item) => {
const opt = document.createElement('option');
@@ -121,11 +146,24 @@ export function initHandoverDomainSelect(root = document) {
on(domainSelect, 'change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex];
if (domainUrlInput) {
domainUrlInput.value = selected?.dataset.url || '';
}
if (domainNoInput) domainNoInput.value = selected?.value || '';
if (domainUrlInput) domainUrlInput.value = selected?.dataset.url || '';
});
// Sync manual inputs → hidden domain_no / domain_url fields
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) {
@@ -148,6 +186,9 @@ export function initHandoverDomainSelect(root = document) {
domainSelect.dispatchEvent(new Event('change'));
}
});
} else if (domainNoInput && domainNoInput.value) {
// Restore hidden value on page reload with validation errors (manual mode)
if (manualNoInput) manualNoInput.value = domainNoInput.value;
}
return {

View File

@@ -6,7 +6,7 @@
*/
import { resolveHost } from '/js/core/app-dom.js';
const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select', 'user-select'];
const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
const EMPTY_API = Object.freeze({ destroy: () => {} });
@@ -385,6 +385,12 @@ function init(root) {
option.textContent = opt.label || '';
input.appendChild(option);
});
} else if (field.type === 'user-select') {
input = document.createElement('select');
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = t['type_user-select'] || 'User selection';
input.appendChild(placeholder);
} else {
input = document.createElement('input');
input.type = field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text';

View File

@@ -0,0 +1,218 @@
/**
* Customer analytics — engagement overview (active vs. silent customers,
* top communication). Reads the global ticket snapshot aggregated server-side.
*/
import { getJson } from '/js/core/app-http.js';
import { resolveHost } from '/js/core/app-dom.js';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveContainer = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-analytics"]')) {
return host;
}
return host.querySelector('.app-details-container[data-app-component="helpdesk-analytics"]');
};
export function initHelpdeskAnalytics(root = document) {
const container = resolveContainer(root);
if (!container) {
return EMPTY_API;
}
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 dataUrl = container.dataset.analyticsUrl;
const debitorBaseUrl = container.dataset.debitorBaseUrl || '';
const d = (key, fallback) => container.dataset[key] || fallback;
const elLoading = document.getElementById('analytics-loading');
const elError = document.getElementById('analytics-error');
const elTruncated = document.getElementById('analytics-truncated');
const elContent = document.getElementById('analytics-content');
const elTiles = document.getElementById('analytics-tiles');
const elSilentTable = document.getElementById('analytics-silent-table');
const elSilentBody = document.getElementById('analytics-silent-body');
const elSilentEmpty = document.getElementById('analytics-silent-empty');
const elTopTable = document.getElementById('analytics-top-table');
const elTopBody = document.getElementById('analytics-top-body');
const elTopEmpty = document.getElementById('analytics-top-empty');
const elRefresh = container.querySelector('button[name="analytics-refresh"]');
const periodSelector = document.getElementById('analytics-period-selector');
let currentPeriod = 90;
const showState = (state) => {
if (elLoading) elLoading.hidden = state !== 'loading';
if (elError) elError.hidden = state !== 'error';
if (elContent) elContent.hidden = state !== 'success';
};
const h = (tag, cls, text) => {
const element = document.createElement(tag);
if (cls) element.className = cls;
if (text !== undefined) element.textContent = text;
return element;
};
const customerCell = (row) => {
const td = h('td');
const name = row.customer_name || row.customer_no;
if (debitorBaseUrl && row.customer_no) {
const link = h('a', '', name);
link.href = debitorBaseUrl + encodeURIComponent(row.customer_no);
td.appendChild(link);
} else {
td.textContent = name;
}
if (row.customer_name && row.customer_no) {
td.appendChild(h('span', 'helpdesk-analytics-cust-no', ` ${row.customer_no}`));
}
return td;
};
const lastContactText = (row) => {
if (row.days_since_contact === null || row.days_since_contact === undefined) {
return d('labelNeverShort', 'never');
}
if (row.days_since_contact === 0) {
return row.last_contact_iso || '';
}
return `${row.last_contact_iso || ''} · ${row.days_since_contact} ${d('labelDaysAgo', 'days ago')}`;
};
// Mirror templates/partials/app-tile.phtml so the .app-tile CSS applies, but
// render a non-navigating <div> — these are stat tiles, not links.
const buildTile = ({ icon, tone, count, label }) => {
const tile = h('div', 'app-tile helpdesk-analytics-tile');
if (tone) tile.dataset.tone = tone;
const iconWrap = h('span', 'app-tile-icon');
iconWrap.appendChild(h('i', `bi ${icon}`));
tile.appendChild(iconWrap);
tile.appendChild(h('span', 'app-tile-count', String(count)));
tile.appendChild(h('span', 'app-tile-label', label));
return tile;
};
const renderTiles = (summary) => {
if (!elTiles) return;
elTiles.replaceChildren();
elTiles.appendChild(buildTile({
icon: 'bi-people', tone: 'blue',
count: summary.total_customers || 0, label: d('labelTotal', 'Customers'),
}));
elTiles.appendChild(buildTile({
icon: 'bi-chat-dots', tone: 'emerald',
count: summary.active || 0, label: d('labelActive', 'Active'),
}));
elTiles.appendChild(buildTile({
icon: 'bi-exclamation-triangle', tone: 'amber',
count: summary.silent || 0, label: d('labelSilent', 'No contact'),
}));
elTiles.appendChild(buildTile({
icon: 'bi-question-circle', tone: 'neutral',
count: summary.no_contact_ever || 0, label: d('labelNever', 'Never in contact'),
}));
};
const renderSilent = (rows) => {
if (!elSilentBody || !elSilentTable || !elSilentEmpty) return;
elSilentBody.replaceChildren();
if (!rows || rows.length === 0) {
elSilentTable.hidden = true;
elSilentEmpty.hidden = false;
return;
}
elSilentEmpty.hidden = true;
elSilentTable.hidden = false;
for (const row of rows) {
const tr = h('tr');
tr.appendChild(customerCell(row));
const contactTd = h('td', '', lastContactText(row));
if (row.status === 'no_contact_ever') {
contactTd.classList.add('helpdesk-analytics-never');
}
tr.appendChild(contactTd);
tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0)));
elSilentBody.appendChild(tr);
}
};
const renderTop = (rows) => {
if (!elTopBody || !elTopTable || !elTopEmpty) return;
elTopBody.replaceChildren();
if (!rows || rows.length === 0) {
elTopTable.hidden = true;
elTopEmpty.hidden = false;
return;
}
elTopEmpty.hidden = true;
elTopTable.hidden = false;
for (const row of rows) {
const tr = h('tr');
tr.appendChild(customerCell(row));
tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0)));
tr.appendChild(h('td', '', lastContactText(row)));
elTopBody.appendChild(tr);
}
};
const fetchData = async (refresh = false) => {
showState('loading');
if (elTruncated) elTruncated.hidden = true;
const params = new URLSearchParams({ periodDays: String(currentPeriod) });
if (refresh) params.set('refresh', '1');
try {
const json = await getJson(`${dataUrl}?${params.toString()}`, {
signal: requestController.signal,
});
if (!json.ok) { showState('error'); return; }
renderTiles(json.summary || {});
renderSilent(json.silent || []);
renderTop(json.top_communication || []);
showState('success');
if (json.meta && json.meta.truncated && elTruncated) {
elTruncated.hidden = false;
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
showState('error');
}
};
on(periodSelector, 'change', (event) => {
const radio = event.target.closest('input[name="analytics-period"]');
if (!radio) return;
const period = parseInt(radio.value, 10);
if (!period || period === currentPeriod) return;
currentPeriod = period;
void fetchData();
});
on(elRefresh, 'click', () => {
void fetchData(true);
});
void fetchData();
return {
destroy: () => {
listenerController.abort();
requestController.abort();
},
};
}

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

@@ -0,0 +1,120 @@
# 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); picks custom template or built-in PHTML default
SecurityReportSettingsGateway.php— report HTML/CSS template (core settings table, clear text)
SecurityReportTemplateService.php— {{var}} substitution into the admin template + default scaffold (pure)
SecurityReportLogoService.php — report logo upload/serve (storage/security/report-logo, ImageUploadTrait)
pages/security/ — actions (*.php) + views (*.phtml)
checks/report($id).php — streams the customer report PDF (gated on steps 15 complete)
report-design/ — report look config: logo upload + HTML/CSS template editor; logo-file() serves the logo (settings.manage)
templates/aside-security-panel.phtml— sidebar navigation
templates/pdf/customer-report.phtml — built-in default report layout (used when no custom template is stored)
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/ — 10 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`, `security/report-design`
(customer report look: logo + HTML/CSS template), plus data endpoints
`security/checks-data`, `security/templates-data`, `security/customer-search-data`,
`security/customer-domains-data`, `security/settings/test-connection-data`,
`security/report-design/logo-file` (serves the uploaded report logo).
`report-design` reuses the `security.settings.manage` permission (no new permission key).
## 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,204 @@
{
"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.",
"Archive this security check? It will become read-only.": "Diesen Security Check archivieren? Er wird schreibgeschützt.",
"This security check is archived and read-only.": "Dieser Security Check ist archiviert und schreibgeschützt.",
"Report design": "Berichtsdesign",
"Customer report design": "Kundenbericht-Design",
"Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.": "Legen Sie das Aussehen des Kunden-Security-Check-Berichts (PDF) fest: Logo hochladen und die HTML/CSS-Vorlage bearbeiten, die ins PDF gerendert wird.",
"Report logo": "Berichtslogo",
"Report logo removed": "Berichtslogo entfernt",
"Current logo": "Aktuelles Logo",
"Delete this logo?": "Dieses Logo löschen?",
"Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.": "Erlaubte Dateitypen: SVG, PNG, JPG, WEBP. Max. 5 MB.",
"Template (HTML & CSS)": "Vorlage (HTML & CSS)",
"Leave empty to use the built-in default template. Scripts and remote resources are not executed.": "Leer lassen, um die integrierte Standardvorlage zu verwenden. Skripte und externe Ressourcen werden nicht ausgeführt.",
"The template is too large.": "Die Vorlage ist zu groß.",
"Available variables": "Verfügbare Variablen",
"These placeholders are replaced when the report is generated:": "Diese Platzhalter werden beim Erstellen des Berichts ersetzt:",
"Logo image source (data URI) — use in <img src=\"{{logo_src}}\">": "Logo-Bildquelle (Data-URI) verwenden in <img src=\"{{logo_src}}\">",
"Report title": "Berichtstitel",
"Customer name": "Kundenname",
"Customer number": "Kundennummer",
"Responsible person": "Verantwortliche Person",
"Official customer report": "Offizieller Kundenbericht",
"Official customer report text (written by the owner on the report step)": "Offizieller Kundenbericht-Text (vom Verantwortlichen im Berichtsschritt verfasst)",
"Completion percentage": "Fortschritt in Prozent",
"Completed technical checks": "Abgeschlossene technische Prüfungen",
"Total technical checks": "Technische Prüfungen gesamt",
"Generation date": "Erstellungsdatum",
"Application name": "Anwendungsname",
"Technical checklist table (HTML)": "Technische Checklisten-Tabelle (HTML)",
"Process steps table (HTML)": "Prozessschritte-Tabelle (HTML)",
"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,204 @@
{
"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.",
"Archive this security check? It will become read-only.": "Archive this security check? It will become read-only.",
"This security check is archived and read-only.": "This security check is archived and read-only.",
"Report design": "Report design",
"Customer report design": "Customer report design",
"Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.": "Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.",
"Report logo": "Report logo",
"Report logo removed": "Report logo removed",
"Current logo": "Current logo",
"Delete this logo?": "Delete this logo?",
"Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.": "Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.",
"Template (HTML & CSS)": "Template (HTML & CSS)",
"Leave empty to use the built-in default template. Scripts and remote resources are not executed.": "Leave empty to use the built-in default template. Scripts and remote resources are not executed.",
"The template is too large.": "The template is too large.",
"Available variables": "Available variables",
"These placeholders are replaced when the report is generated:": "These placeholders are replaced when the report is generated:",
"Logo image source (data URI) — use in <img src=\"{{logo_src}}\">": "Logo image source (data URI) — use in <img src=\"{{logo_src}}\">",
"Report title": "Report title",
"Customer name": "Customer name",
"Customer number": "Customer number",
"Responsible person": "Responsible person",
"Official customer report": "Official customer report",
"Official customer report text (written by the owner on the report step)": "Official customer report text (written by the owner on the report step)",
"Completion percentage": "Completion percentage",
"Completed technical checks": "Completed technical checks",
"Total technical checks": "Total technical checks",
"Generation date": "Generation date",
"Application name": "Application name",
"Technical checklist table (HTML)": "Technical checklist table (HTML)",
"Process steps table (HTML)": "Process steps table (HTML)",
"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,95 @@
<?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\SecurityReportLogoService;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
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)
));
// --- Customer report presentation (logo + HTML/CSS template) ---
$container->set(SecurityReportSettingsGateway::class, static fn (AppContainer $c): SecurityReportSettingsGateway => new SecurityReportSettingsGateway(
$c->get(SettingsMetadataGateway::class)
));
$container->set(SecurityReportTemplateService::class, static fn (): SecurityReportTemplateService => new SecurityReportTemplateService());
$container->set(SecurityReportLogoService::class, static fn (): SecurityReportLogoService => new SecurityReportLogoService());
$container->set(SecurityReportPdfService::class, static fn (AppContainer $c): SecurityReportPdfService => new SecurityReportPdfService(
$c->get(SecurityCheckProcess::class),
$c->get(BrandingLogoService::class),
$c->get(TenantLogoService::class),
$c->get(SecurityReportSettingsGateway::class),
$c->get(SecurityReportTemplateService::class),
$c->get(SecurityReportLogoService::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,276 @@
<?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';
/** Field key of the official, customer-facing report written on the final step. */
public const FIELD_CUSTOMER_REPORT = 'customer_report';
/**
* 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, rows?: int}>}>
*/
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' => self::STEP_REPORT,
'label' => 'Create report & notify customer',
'description' => 'Create the report for the customer and notify them.',
'kind' => self::KIND_STEP,
'fields' => [
['key' => self::FIELD_CUSTOMER_REPORT, 'label' => 'Official customer report', 'type' => self::FIELD_TEXTAREA, 'required' => true, 'rows' => 12],
],
],
];
}
/**
* 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, rows?: int}>
*/
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,156 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Service\Image\ImageUploadTrait;
/**
* Stores and resolves the logo shown on the customer security-check report PDF.
*
* A single global asset (the module has no per-tenant settings), kept on the
* filesystem under storage/security/report-logo (GR-SEC-006: uploads live under
* storage/ only, are MIME-validated, and SVGs are sanitised). Mirrors the core
* BrandingLogoService pattern via ImageUploadTrait.
*
* @api Consumed by the report-design page (upload/preview/delete) and
* SecurityReportPdfService (logo embedding).
*/
final class SecurityReportLogoService
{
use ImageUploadTrait;
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [64, 128, 256];
private const DEFAULT_SIZE = 256;
public function storageBase(): string
{
return self::imageStorageBase();
}
public function reportLogoDir(): string
{
return $this->storageBase() . '/security/report-logo';
}
public function findLogoPath(?int $size = null): ?string
{
$dir = $this->reportLogoDir();
if (!is_dir($dir)) {
return null;
}
if ($size) {
$variant = $this->findVariantPath($dir, $this->normalizeSize($size));
if ($variant) {
return $variant;
}
}
$defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE);
if ($defaultVariant) {
return $defaultVariant;
}
$original = self::imageFindOriginalPath($dir);
return $original ?: null;
}
public function hasLogo(): bool
{
$path = $this->findLogoPath();
return $path ? is_file($path) : false;
}
public function delete(): bool
{
$dir = $this->reportLogoDir();
if (!is_dir($dir)) {
return true;
}
$matches = array_merge(
glob($dir . '/logo-*.*') ?: [],
glob($dir . '/logo.*') ?: [],
glob($dir . '/original.*') ?: []
);
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
return true;
}
/**
* @param array<string, mixed> $file A single $_FILES entry (tmp_name, error, size)
* @return array{ok: bool, error?: string, path?: string, mime?: string}
*/
public function saveUpload(array $file): array
{
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = (string) $file['tmp_name'];
$mime = $this->detectMime($tmpPath);
$isSvg = self::imageIsSvgUpload($mime, $tmpPath);
$ext = self::imageExtensionForMime($mime, $isSvg);
if (!$ext) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if ($isSvg && !self::imageIsSafeSvg($tmpPath)) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = $this->reportLogoDir();
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
$this->delete();
$originalPath = $dir . '/original.' . $ext;
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
// SVGs are embedded as-is; raster images get downscaled variants when GD is available.
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
if (!$isSvg && self::imageCanResize()) {
foreach (self::SIZES as $size) {
$target = $dir . '/logo-' . $size . '.' . $variantExt;
self::imageResizeAndFit($originalPath, $target, $size, $size, $variantExt);
}
}
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public function detectMime(string $path): string
{
return self::imageDetectMime($path);
}
private function normalizeSize(int $size): int
{
return in_array($size, self::SIZES, true) ? $size : self::DEFAULT_SIZE;
}
// Multiple extensions may coexist (e.g. after a format change) — pick the freshest.
private function findVariantPath(string $dir, int $size): ?string
{
$matches = glob($dir . '/logo-' . $size . '.*');
if (!$matches) {
return null;
}
usort($matches, static fn ($a, $b) => filemtime($b) <=> filemtime($a));
return $matches[0];
}
}

View File

@@ -0,0 +1,321 @@
<?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,
private readonly ?SecurityReportSettingsGateway $reportSettings = null,
private readonly ?SecurityReportTemplateService $templateService = null,
private readonly ?SecurityReportLogoService $reportLogoService = 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->renderReportHtml($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);
// The owner-authored, customer-facing report text captured on the final step.
$reportFields = is_array($processState[SecurityCheckProcess::STEP_REPORT]['fields'] ?? null)
? $processState[SecurityCheckProcess::STEP_REPORT]['fields']
: [];
return [
'title' => t('Security check report'),
'customer_report' => trim((string) ($reportFields[SecurityCheckProcess::FIELD_CUSTOMER_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'] ?? '')),
'owner' => (string) ($check['owner_name'] ?? ''),
'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;
}
/**
* Produce the report HTML. When a custom template is configured (and the
* collaborating services are wired), it is filled with the report context;
* otherwise the built-in PHTML default layout is used. Keeping the default
* on the proven PHTML path makes the feature purely additive.
*
* @param array<string, mixed> $report
*/
private function renderReportHtml(array $report): string
{
if ($this->reportSettings !== null && $this->templateService !== null) {
$template = $this->reportSettings->getTemplateHtml();
if ($template !== '') {
return $this->templateService->render($template, $report);
}
}
return $this->renderTemplate($report);
}
/**
* @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: configured report logo → 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->reportLogoService !== null && $this->reportLogoService->hasLogo()) {
$logoPath = $this->reportLogoService->findLogoPath(256);
if ($logoPath && is_file($logoPath)) {
$path = $logoPath;
$mime = $this->reportLogoService->detectMime($logoPath);
}
}
if ($path === '' && $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,64 @@
<?php
namespace MintyPHP\Module\Security\Service;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
/**
* Global presentation settings for the customer security-check report PDF.
*
* Stores the admin-authored HTML/CSS report template in the core `settings`
* table (GR-CORE-011). The template is not a secret, so it is stored in clear
* text (unlike the BC credentials in SecurityBcSettingsGateway). Setting keys
* are prefixed with 'security.' to avoid cross-module collisions.
*
* The uploaded report logo is a binary asset and lives on the filesystem —
* see SecurityReportLogoService — not here.
*
* @api Consumed by the Security report-design page and SecurityReportPdfService.
*/
class SecurityReportSettingsGateway
{
public const KEY_TEMPLATE_HTML = 'security.report_template_html';
/** Soft cap so the settings row stays sane; a real template is a few KB. */
public const MAX_TEMPLATE_LENGTH = 200000;
public function __construct(
private readonly SettingsMetadataGateway $settingsMetadataGateway
) {
}
/**
* The stored custom template, or '' when none is configured (the PDF service
* then falls back to its built-in default layout).
*/
public function getTemplateHtml(): string
{
return trim((string) ($this->settingsMetadataGateway->getValue(self::KEY_TEMPLATE_HTML) ?? ''));
}
public function hasCustomTemplate(): bool
{
return $this->getTemplateHtml() !== '';
}
/**
* Persist the template. Passing null or an empty/blank string clears it,
* restoring the built-in default. Returns false if the value exceeds the
* soft length cap.
*/
public function setTemplateHtml(?string $html): bool
{
$html = trim((string) ($html ?? ''));
if ($html !== '' && strlen($html) > self::MAX_TEMPLATE_LENGTH) {
return false;
}
return $this->settingsMetadataGateway->set(
self::KEY_TEMPLATE_HTML,
$html !== '' ? $html : null,
self::KEY_TEMPLATE_HTML
);
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace MintyPHP\Module\Security\Service;
/**
* Renders the admin-authored HTML/CSS report template into a final HTML string
* by substituting {{placeholder}} variables with values from the report context.
*
* Pure transform (no I/O): the consuming SecurityReportPdfService builds the
* context, this fills the template, Dompdf rasterises the result. Scalar values
* are HTML-escaped so customer/BC data can never break out of the surrounding
* markup; the {{checklist}}, {{process}} and {{logo_src}} variables are built
* here as trusted HTML / a data URI. Substitution is a single simultaneous pass
* (strtr), so an injected value can never be re-expanded as another placeholder.
*
* The template text itself is authored by a settings-manager (gated by
* security.settings.manage) and rendered with Dompdf's remote fetching and PHP
* execution disabled — it shapes a downloaded PDF, not an app web page.
*
* @api Consumed by SecurityReportPdfService and the report-design page (variable
* reference + default scaffold).
*/
final class SecurityReportTemplateService
{
/**
* Fill a template with the report context.
*
* @param array<string, mixed> $context Output of SecurityReportPdfService::buildReportContext()
* plus app_name / logo_data_uri / generated_at.
*/
public function render(string $template, array $context): string
{
return strtr($template, $this->buildVariableMap($context));
}
/**
* Placeholder => already-translated description, for the UI variable reference
* and to document the contract in one place. Keys are the bare token names
* (without braces); render() wraps them in {{ }}.
*
* @return array<string, string>
*/
public function availableVariables(): array
{
return [
'customer_report' => t('Official customer report text (written by the owner on the report step)'),
'logo_src' => t('Logo image source (data URI) — use in <img src="{{logo_src}}">'),
'title' => t('Report title'),
'customer_name' => t('Customer name'),
'customer_no' => t('Customer number'),
'domain' => t('Domain'),
'product' => t('Software product'),
'owner' => t('Responsible person'),
'status' => t('Status'),
'percent' => t('Completion percentage'),
'checks_done' => t('Completed technical checks'),
'checks_total' => t('Total technical checks'),
'generated_at' => t('Generation date'),
'app_name' => t('Application name'),
'checklist' => t('Technical checklist table (HTML)'),
'process' => t('Process steps table (HTML)'),
];
}
/**
* Build the {{token}} => value map. Scalars are HTML-escaped; structural
* variables are trusted HTML built from the (already escaped) context.
*
* @param array<string, mixed> $context
* @return array<string, string>
*/
private function buildVariableMap(array $context): array
{
$summary = is_array($context['summary'] ?? null) ? $context['summary'] : [];
$scalars = [
'logo_src' => (string) ($context['logo_data_uri'] ?? ''),
'title' => (string) ($context['title'] ?? ''),
'customer_name' => (string) ($context['customer_name'] ?? ''),
'customer_no' => (string) ($context['customer_no'] ?? ''),
'domain' => (string) ($context['domain'] ?? ''),
'product' => (string) ($context['product'] ?? ''),
'owner' => (string) ($context['owner'] ?? ''),
'status' => (string) ($context['status_label'] ?? ''),
'percent' => (string) (int) ($summary['percent'] ?? 0),
'checks_done' => (string) (int) ($summary['tech_done'] ?? 0),
'checks_total' => (string) (int) ($summary['tech_total'] ?? 0),
'generated_at' => (string) ($context['generated_at'] ?? ''),
'app_name' => (string) ($context['app_name'] ?? ''),
];
$map = [];
foreach ($scalars as $key => $value) {
$map['{{' . $key . '}}'] = $this->esc($value);
}
// Owner-authored prose: escape, then keep the author's line breaks.
$map['{{customer_report}}'] = nl2br($this->esc((string) ($context['customer_report'] ?? '')));
$map['{{checklist}}'] = $this->buildChecklistHtml(
is_array($context['tech_sections'] ?? null) ? $context['tech_sections'] : []
);
$map['{{process}}'] = $this->buildProcessHtml(
is_array($context['steps'] ?? null) ? $context['steps'] : []
);
return $map;
}
/**
* @param array<int, mixed> $sections
*/
private function buildChecklistHtml(array $sections): string
{
if ($sections === []) {
return '<p class="muted">' . $this->esc(t('This product has no technical checklist items defined yet.')) . '</p>';
}
$rows = '';
foreach ($sections as $section) {
if (!is_array($section)) {
continue;
}
$label = trim((string) ($section['label'] ?? ''));
if ($label !== '') {
$rows .= '<tr class="section-row"><td colspan="2">' . $this->esc($label) . '</td></tr>';
}
foreach ((is_array($section['items'] ?? null) ? $section['items'] : []) as $item) {
if (!is_array($item)) {
continue;
}
$state = !empty($item['done'])
? '<span class="ok">&#10003; ' . $this->esc(t('Completed')) . '</span>'
: '<span class="pending">' . $this->esc(t('Not completed')) . '</span>';
$note = trim((string) ($item['note'] ?? ''));
$noteHtml = $note !== '' ? '<div class="note">' . $this->esc($note) . '</div>' : '';
$rows .= '<tr><td class="state">' . $state . '</td><td>'
. $this->esc((string) ($item['label'] ?? '')) . $noteHtml . '</td></tr>';
}
}
return '<table class="checklist">' . $rows . '</table>';
}
/**
* @param array<int, mixed> $steps
*/
private function buildProcessHtml(array $steps): string
{
$rows = '';
foreach ($steps as $step) {
if (!is_array($step)) {
continue;
}
$state = !empty($step['done'])
? '<span class="ok">&#10003; ' . $this->esc(t('Completed')) . '</span>'
: '<span class="pending">' . $this->esc(t('Open')) . '</span>';
$completedAt = trim((string) ($step['completed_at'] ?? ''));
$when = $completedAt !== '' ? ' <span class="muted">&middot; ' . $this->esc($completedAt) . '</span>' : '';
$rows .= '<tr><td class="state">' . $state . '</td><td>'
. $this->esc((string) ($step['number'] ?? '')) . '. '
. $this->esc((string) ($step['label'] ?? '')) . $when . '</td></tr>';
}
return '<table class="checklist">' . $rows . '</table>';
}
/**
* A ready-to-edit starter template offered in the editor. Faithful to the
* built-in default layout so admins have a working baseline to customise.
*/
public function defaultTemplateHtml(): string
{
return <<<'HTML'
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<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: 110px; 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="{{logo_src}}" alt="" class="header-logo">
<h1>{{title}}</h1>
<div class="muted">{{generated_at}}</div>
</div>
<table class="meta">
<tr><td class="label">Customer</td><td>{{customer_name}} ({{customer_no}})</td></tr>
<tr><td class="label">Domain</td><td>{{domain}}</td></tr>
<tr><td class="label">Software product</td><td>{{product}}</td></tr>
<tr><td class="label">Responsible</td><td>{{owner}}</td></tr>
<tr><td class="label">Status</td><td>{{status}}</td></tr>
</table>
<h2>Result</h2>
<div class="summary">{{checks_done}} / {{checks_total}} ({{percent}}%)</div>
<div class="bar"><div class="bar-fill" style="width: {{percent}}%;"></div></div>
<h2>Report</h2>
<div class="customer-report">{{customer_report}}</div>
<h2>Technical checklist</h2>
{{checklist}}
<h2>Process</h2>
{{process}}
<div class="footer">{{app_name}} &middot; {{title}} &middot; {{generated_at}}</div>
</body>
</html>
HTML;
}
private function esc(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}

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;

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

@@ -0,0 +1,164 @@
<?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'],
['path' => 'security/report-design', 'target' => 'security/report-design'],
['path' => 'security/report-design/logo-file', 'target' => 'security/report-design/logo-file'],
],
'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,259 @@
<?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="<?php e((string) (int) ($field['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"
data-confirm-message="<?php e(t('Archive this security check? It will become read-only.')); ?>"
data-confirm-variant="warning"><i class="bi bi-archive"></i> <?php e(t('Archive check')); ?></button>
<?php endif; ?>
</form>
<form method="post">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" name="action" value="delete" class="danger outline small"
data-confirm-message="<?php e(t('Delete this security check? This cannot be undone.')); ?>"
data-confirm-variant="danger"><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,84 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
$request = requestInput();
$reportSettings = app(SecurityReportSettingsGateway::class);
$logoService = app(SecurityReportLogoService::class);
$templateService = app(SecurityReportTemplateService::class);
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), 'security/report-design', 'csrf_expired');
Router::redirect('security/report-design');
return;
}
if ($request->isMethod('POST')) {
$action = (string) $request->body('action', 'save');
if ($action === 'delete_logo') {
$logoService->delete();
Flash::success(t('Report logo removed'), 'security/report-design', 'report_logo_removed');
Router::redirect('security/report-design');
return;
}
$errorBag = formErrors();
if (!$reportSettings->setTemplateHtml((string) $request->body('template_html', ''))) {
$errorBag->add('template_html', t('The template is too large.'));
}
// Only touch the logo when a file was actually submitted.
$upload = $request->filesAll()['logo'] ?? [];
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$result = $logoService->saveUpload($upload);
if (!($result['ok'] ?? false)) {
$errorBag->addGlobal((string) ($result['error'] ?? t('Upload failed')));
}
}
if ($errorBag->hasAny()) {
flashFormErrors($errorBag, 'security/report-design', 'report_design_error');
} else {
Flash::success(t('Settings saved'), 'security/report-design', 'report_design_saved');
}
Router::redirect('security/report-design');
return;
}
$hasCustomTemplate = $reportSettings->hasCustomTemplate();
// Prefill the editor with the built-in default scaffold when nothing is stored,
// so the admin has a working baseline to customise.
$templateHtml = $hasCustomTemplate ? $reportSettings->getTemplateHtml() : $templateService->defaultTemplateHtml();
$variables = $templateService->availableVariables();
$hasLogo = $logoService->hasLogo();
$logoVersion = '';
if ($hasLogo) {
$logoPath = $logoService->findLogoPath(256);
$logoVersion = ($logoPath && is_file($logoPath)) ? (string) filemtime($logoPath) : '';
}
Buffer::set('title', t('Customer report design'));
Buffer::set('style_groups', json_encode(['security']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Security'), 'path' => 'security/checks'],
['label' => t('Customer report design')],
];

View File

@@ -0,0 +1,75 @@
<?php
$templateHtml = (string) ($templateHtml ?? '');
$variables = is_array($variables ?? null) ? $variables : [];
$hasLogo = (bool) ($hasLogo ?? false);
$logoVersion = (string) ($logoVersion ?? '');
$logoCurrentSrc = '';
if ($hasLogo) {
$logoCurrentSrc = lurl('security/report-design/logo-file') . '?size=256'
. ($logoVersion !== '' ? '&v=' . rawurlencode($logoVersion) : '');
}
?>
<div class="app-details-container">
<section data-tab-panel data-tab-panel-wide>
<?php
$titlebar = [
'title' => t('Customer report design'),
'actions' => [
['label' => t('Save'), 'type' => 'submit', 'form' => 'security-report-design-form', 'class' => 'primary', 'tone' => 'success'],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<p class="app-muted"><?php e(t('Configure how the customer security-check report PDF looks: upload a logo and edit the HTML/CSS template that is rendered to the PDF.')); ?></p>
<div class="app-details-card">
<h3><?php e(t('Report logo')); ?></h3>
<?php
$fileUpload = [
'name' => 'logo',
'accept' => 'image/svg+xml,image/png,image/jpeg,image/webp',
'hint' => t('Allowed file types: SVG, PNG, JPG, WEBP. Max 5 MB.'),
'currentSrc' => $logoCurrentSrc,
'currentLabel' => t('Current logo'),
'deleteConfirm' => t('Delete this logo?'),
'formId' => 'security-report-design-form',
'deleteFormId' => 'security-report-logo-delete-form',
];
require templatePath('partials/app-file-upload.phtml');
?>
</div>
<form method="post" id="security-report-design-form" enctype="multipart/form-data">
<div class="app-details-card">
<h3><?php e(t('Template (HTML & CSS)')); ?></h3>
<p class="app-muted"><?php e(t('Leave empty to use the built-in default template. Scripts and remote resources are not executed.')); ?></p>
<div class="security-template-editor">
<textarea name="template_html" rows="22" spellcheck="false" autocomplete="off" wrap="off"><?php e($templateHtml); ?></textarea>
</div>
<?php if ($variables !== []): ?>
<div class="security-variable-reference">
<h4><?php e(t('Available variables')); ?></h4>
<p class="app-muted"><?php e(t('These placeholders are replaced when the report is generated:')); ?></p>
<dl class="security-variable-list">
<?php foreach ($variables as $token => $description): ?>
<dt><code><?php e('{{' . $token . '}}'); ?></code></dt>
<dd><?php e((string) $description); ?></dd>
<?php endforeach; ?>
</dl>
</div>
<?php endif; ?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
<form method="post" id="security-report-logo-delete-form" hidden>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<input type="hidden" name="action" value="delete_logo">
</form>
</section>
</div>

View File

@@ -0,0 +1,31 @@
<?php
use MintyPHP\Module\Security\SecurityAuthorizationPolicy;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use MintyPHP\Support\Guard;
// Streams the report logo image directly — no view template for this route.
define('MINTY_ALLOW_OUTPUT', true);
Guard::requireLogin();
Guard::requireAbilityOrForbidden(SecurityAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
$logoService = app(SecurityReportLogoService::class);
$query = requestInput()->queryAll();
$size = isset($query['size']) ? (int) $query['size'] : null;
$path = $logoService->findLogoPath($size);
if (!$path || !is_file($path)) {
http_response_code(404);
return;
}
$mime = $logoService->detectMime($path);
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: sandbox');
header('Cache-Control: private, max-age=300');
header('Content-Length: ' . filesize($path));
readfile($path);

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,89 @@
<?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);
$reportDesignActive = navActive('security/report-design', 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,
],
[
'label' => t('Report design'),
'path' => 'security/report-design',
'active' => $reportDesignActive,
'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,235 @@
<?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 testReportStepRequiresOfficialCustomerReportField(): void
{
$process = new SecurityCheckProcess();
$fields = $process->stepFields(SecurityCheckProcess::STEP_REPORT);
$this->assertSame(
[SecurityCheckProcess::FIELD_CUSTOMER_REPORT],
array_column($fields, 'key')
);
// The owner must fill it before the final step can be completed.
$this->assertContains(
SecurityCheckProcess::FIELD_CUSTOMER_REPORT,
$process->requiredFieldKeys(SecurityCheckProcess::STEP_REPORT)
);
}
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,351 @@
<?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',
];
$step[SecurityCheckProcess::STEP_REPORT]['fields'] = [
SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'All checks passed; no action required.',
];
$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']);
$this->assertSame(
'All checks passed; no action required.',
$process_state[SecurityCheckProcess::STEP_REPORT]['fields'][SecurityCheckProcess::FIELD_CUSTOMER_REPORT]
);
$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,81 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportLogoService;
use PHPUnit\Framework\TestCase;
/**
* Service-behaviour test. As with TenantLogoServiceTest, the successful upload
* path is asserted indirectly (MIME/size guards, no directory creation on
* rejection) because move_uploaded_file() needs a real HTTP-uploaded file and
* cannot run under phpunit. The move step is covered by manual smoke testing.
*/
class SecurityReportLogoServiceTest extends TestCase
{
private SecurityReportLogoService $service;
protected function setUp(): void
{
$this->service = new SecurityReportLogoService();
}
public function testReportLogoDirIsScopedToSecurityReportLogo(): void
{
$this->assertStringEndsWith('/security/report-logo', $this->service->reportLogoDir());
}
public function testSaveUploadRejectsMissingFile(): void
{
$this->assertFalse($this->service->saveUpload([])['ok']);
}
public function testSaveUploadRejectsUploadError(): void
{
$result = $this->service->saveUpload([
'tmp_name' => '/tmp/whatever',
'error' => UPLOAD_ERR_NO_FILE,
'size' => 100,
]);
$this->assertFalse($result['ok']);
}
public function testSaveUploadRejectsOversizedFile(): void
{
$result = $this->service->saveUpload([
'tmp_name' => '/tmp/whatever',
'error' => UPLOAD_ERR_OK,
'size' => 6 * 1024 * 1024,
]);
$this->assertFalse($result['ok']);
}
public function testSaveUploadRejectsNonImageContent(): void
{
$tmp = tempnam(sys_get_temp_dir(), 'sec-logo-test');
$this->assertNotFalse($tmp);
file_put_contents($tmp, "just some plain text, not an image\n");
try {
$result = $this->service->saveUpload([
'tmp_name' => $tmp,
'error' => UPLOAD_ERR_OK,
'size' => filesize($tmp),
]);
$this->assertFalse($result['ok']);
// Rejection must happen before any directory/file is created.
$this->assertFalse(is_dir($this->service->reportLogoDir() . '/__never__'));
} finally {
@unlink($tmp);
}
}
public function testDeleteIsIdempotentWhenNothingStored(): void
{
// delete() is safe to call even when the directory does not exist.
$this->assertTrue($this->service->delete());
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityCheckProcess;
use MintyPHP\Module\Security\Service\SecurityReportPdfService;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
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'];
}
$processState[SecurityCheckProcess::STEP_REPORT]['fields'] = [
SecurityCheckProcess::FIELD_CUSTOMER_REPORT => 'No critical findings.',
];
$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',
'owner_name' => 'Jane Doe',
'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->assertSame('Jane Doe', $context['owner']);
$this->assertSame('No critical findings.', $context['customer_report']);
$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['customer_report']);
$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);
}
public function testRenderCustomerReportPdfUsesConfiguredCustomTemplate(): void
{
$branding = $this->createMock(BrandingLogoService::class);
$branding->method('hasLogo')->willReturn(false);
$settings = $this->createMock(SecurityReportSettingsGateway::class);
$settings->method('getTemplateHtml')->willReturn(
'<!doctype html><html><body><h1>Custom {{customer_name}}</h1>{{checklist}}</body></html>'
);
$service = new SecurityReportPdfService(
new SecurityCheckProcess(),
$branding,
null,
$settings,
new SecurityReportTemplateService(),
null
);
$pdf = $service->renderCustomerReportPdf($this->enrichedCheck(), ['app_name' => 'CoreCore']);
$this->assertStringStartsWith('%PDF-', $pdf);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportSettingsGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SecurityReportSettingsGatewayTest extends TestCase
{
/**
* @param array<string, string> $values
*/
private function makeGateway(array $values = []): SecurityReportSettingsGateway
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturnCallback(static fn (string $key): ?string => $values[$key] ?? null);
$metadata->method('set')->willReturn(true);
return new SecurityReportSettingsGateway($metadata);
}
public function testTemplateDefaultsToEmpty(): void
{
$gateway = $this->makeGateway();
$this->assertSame('', $gateway->getTemplateHtml());
$this->assertFalse($gateway->hasCustomTemplate());
}
public function testGetTemplateHtmlReturnsTrimmedStoredValue(): void
{
$gateway = $this->makeGateway([
SecurityReportSettingsGateway::KEY_TEMPLATE_HTML => " <h1>{{title}}</h1> ",
]);
$this->assertSame('<h1>{{title}}</h1>', $gateway->getTemplateHtml());
$this->assertTrue($gateway->hasCustomTemplate());
}
public function testSetTemplateHtmlRejectsOversizedTemplate(): void
{
$gateway = $this->makeGateway();
$tooLong = str_repeat('a', SecurityReportSettingsGateway::MAX_TEMPLATE_LENGTH + 1);
$this->assertFalse($gateway->setTemplateHtml($tooLong));
}
public function testSetTemplateHtmlPersistsValueAndClearsOnEmpty(): void
{
$writes = [];
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn(null);
$metadata->method('set')->willReturnCallback(static function (string $key, ?string $value, ?string $description = null) use (&$writes): bool {
$writes[$key] = $value;
return true;
});
$gateway = new SecurityReportSettingsGateway($metadata);
$this->assertTrue($gateway->setTemplateHtml('<p>x</p>'));
$this->assertSame('<p>x</p>', $writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]);
// Blank input clears the setting (restores the built-in default).
$this->assertTrue($gateway->setTemplateHtml(' '));
$this->assertNull($writes[SecurityReportSettingsGateway::KEY_TEMPLATE_HTML]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace MintyPHP\Tests\Module\Security\Service;
use MintyPHP\Module\Security\Service\SecurityReportTemplateService;
use PHPUnit\Framework\TestCase;
class SecurityReportTemplateServiceTest extends TestCase
{
/**
* @return array<string, mixed>
*/
private function context(): array
{
return [
'title' => 'Security check report',
'customer_name' => '<b>Acme</b>',
'customer_no' => 'D-100',
'domain' => 'acme.de',
'product' => 'Intranet',
'owner' => 'Jane Doe',
'status_label' => 'In progress',
'generated_at' => '2026-06-22 10:00',
'app_name' => 'CoreCore',
'customer_report' => "Line one\nLine two",
'logo_data_uri' => 'data:image/png;base64,AAAA',
'summary' => ['percent' => 72, 'tech_done' => 2, 'tech_total' => 3],
'tech_sections' => [
['label' => 'Transport', 'items' => [
['label' => 'TLS enforced', 'done' => true, 'note' => 'TLS 1.3'],
['label' => 'HSTS header', 'done' => false, 'note' => ''],
]],
],
'steps' => [
['number' => 1, 'label' => 'Kickoff', 'done' => true, 'completed_at' => '2026-06-18'],
],
];
}
public function testRenderSubstitutesScalarsAndEscapesData(): void
{
$service = new SecurityReportTemplateService();
$out = $service->render(
'<h1>{{title}}</h1> who={{customer_name}} dom={{domain}} owner={{owner}} p={{percent}}% src={{logo_src}}',
$this->context()
);
$this->assertStringContainsString('<h1>Security check report</h1>', $out);
$this->assertStringContainsString('dom=acme.de', $out);
$this->assertStringContainsString('owner=Jane Doe', $out);
$this->assertStringContainsString('p=72%', $out);
$this->assertStringContainsString('src=data:image/png;base64,AAAA', $out);
// Customer data is HTML-escaped so it cannot break out of the template markup.
$this->assertStringContainsString('who=&lt;b&gt;Acme&lt;/b&gt;', $out);
$this->assertStringNotContainsString('<b>Acme</b>', $out);
}
public function testRenderCustomerReportEscapesAndPreservesLineBreaks(): void
{
$service = new SecurityReportTemplateService();
$context = $this->context();
$context['customer_report'] = "First line <script>\nSecond line";
$out = $service->render('<section>{{customer_report}}</section>', $context);
// HTML in the owner's text is escaped...
$this->assertStringContainsString('First line &lt;script&gt;', $out);
$this->assertStringNotContainsString('<script>', $out);
// ...and author line breaks survive as <br>.
$this->assertMatchesRegularExpression('/First line &lt;script&gt;<br\s*\/?>\s*Second line/', $out);
}
public function testRenderBuildsChecklistAndProcessHtml(): void
{
$service = new SecurityReportTemplateService();
$out = $service->render('{{checklist}}||{{process}}', $this->context());
$this->assertStringContainsString('Transport', $out);
$this->assertStringContainsString('TLS enforced', $out);
$this->assertStringContainsString('TLS 1.3', $out);
$this->assertStringContainsString('Kickoff', $out);
$this->assertStringContainsString('<table class="checklist">', $out);
}
public function testRenderIsSinglePassAndDoesNotReExpandInjectedTokens(): void
{
$service = new SecurityReportTemplateService();
$context = $this->context();
$context['customer_name'] = '{{title}}';
$context['title'] = 'INJECTED';
$out = $service->render('{{customer_name}}', $context);
// The value substituted for {{customer_name}} must not be re-scanned,
// so the literal token survives and {{title}} is never expanded here.
$this->assertStringContainsString('{{title}}', $out);
$this->assertStringNotContainsString('INJECTED', $out);
}
public function testRenderHandlesEmptyContextSafely(): void
{
$service = new SecurityReportTemplateService();
$out = $service->render('[{{title}}]({{checklist}})', []);
$this->assertStringContainsString('[]', $out);
// No sections -> the "no items" notice instead of a table.
$this->assertStringContainsString('class="muted"', $out);
}
public function testDefaultTemplateContainsCoreVariables(): void
{
$service = new SecurityReportTemplateService();
$html = $service->defaultTemplateHtml();
$this->assertNotSame('', $html);
$this->assertStringContainsString('{{logo_src}}', $html);
$this->assertStringContainsString('{{checklist}}', $html);
$this->assertStringContainsString('{{process}}', $html);
}
public function testAvailableVariablesExposeStructuralTokens(): void
{
$variables = (new SecurityReportTemplateService())->availableVariables();
$this->assertArrayHasKey('customer_report', $variables);
$this->assertArrayHasKey('logo_src', $variables);
$this->assertArrayHasKey('checklist', $variables);
$this->assertArrayHasKey('process', $variables);
}
}

View File

@@ -0,0 +1,602 @@
/* 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);
}
}
/* --- Customer report design (logo + HTML/CSS template editor) --- */
.security-template-editor textarea {
width: 100%;
margin: 0;
font-family: var(--font-mono, ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace);
font-size: 0.8125rem;
line-height: 1.5;
tab-size: 2;
white-space: pre;
resize: vertical;
}
.security-variable-reference {
margin-top: calc(var(--app-spacing) * 1.25);
}
.security-variable-reference h4 {
margin: 0 0 0.25rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 600);
}
.security-variable-list {
display: grid;
grid-template-columns: minmax(12rem, max-content) 1fr;
gap: 0.25rem 1rem;
margin: 0.5rem 0 0;
}
.security-variable-list dt {
margin: 0;
}
.security-variable-list dt code {
font-size: 0.8125rem;
white-space: nowrap;
}
.security-variable-list dd {
margin: 0;
color: var(--app-muted-color, #6a6a6a);
font-size: var(--text-sm, 0.875rem);
}
@media (max-width: 576px) {
.security-variable-list {
grid-template-columns: 1fr;
gap: 0.1rem 0;
}
.security-variable-list dd {
margin-bottom: 0.4rem;
}
}

Some files were not shown because too many files have changed in this diff Show More