Compare commits
39 Commits
c8cf2892c9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14da36e83a | ||
|
|
2924407450 | ||
|
|
7a9702cffa | ||
|
|
3ae289f1b7 | ||
|
|
c20d996ca6 | ||
|
|
a7b13c3973 | ||
|
|
4636374af4 | ||
| 372ca5f66b | |||
| 7a9f8e770a | |||
| 0b7c08ba6b | |||
| 498afc7840 | |||
|
|
0392043ee3 | ||
|
|
e3dfdef76f | ||
|
|
40b94fa891 | ||
|
|
0fabb0c478 | ||
|
|
2968d5e386 | ||
|
|
9bc8799b6b | ||
|
|
e569e2d26c | ||
|
|
57cb2f1017 | ||
|
|
0fc2d3f2c3 | ||
|
|
2b230e756e | ||
|
|
b049e2490b | ||
|
|
16fe1b0db3 | ||
|
|
322c9575f8 | ||
|
|
3ff302cc02 | ||
|
|
c6a919652f | ||
|
|
f4b08e4c73 | ||
|
|
db5c57ea5c | ||
|
|
0a56c741e8 | ||
|
|
668a67c094 | ||
|
|
acf3229f3f | ||
|
|
5367449d80 | ||
|
|
68407b944e | ||
|
|
c12d155e23 | ||
|
|
3f08f6189e | ||
|
|
b8fc55bcc2 | ||
|
|
6cfc8e7533 | ||
|
|
2e26cce91f | ||
|
|
8e8ca4d5e7 |
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
93
.github/workflows/deploy.yaml
vendored
Normal file
93
.github/workflows/deploy.yaml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
qa:
|
||||
name: QA gates
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: breadcrumb-ci
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create .env for CI
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Start project services
|
||||
run: |
|
||||
docker compose up -d --force-recreate php db memcached
|
||||
sleep 20
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: |
|
||||
docker compose exec -T php bash -c "cd /var/www && composer install --no-interaction --prefer-dist --optimize-autoloader"
|
||||
|
||||
- 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
|
||||
|
||||
build-and-deploy:
|
||||
name: Build & deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs: qa
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build production image
|
||||
run: |
|
||||
docker build \
|
||||
--target prod \
|
||||
-f docker/php/Dockerfile \
|
||||
-t breadcrumb-the-shire:latest \
|
||||
.
|
||||
|
||||
- name: Export image to tar.gz
|
||||
run: docker save breadcrumb-the-shire:latest | gzip > breadcrumb-the-shire.tar.gz
|
||||
|
||||
- name: Copy image to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: breadcrumb-the-shire.tar.gz
|
||||
target: /root/dockerfiles-breadcrumb-infra/projects/
|
||||
|
||||
- name: Copy updated deploy files to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "deploy/docker-compose.yml,deploy/nginx/"
|
||||
target: /root/dockerfiles-breadcrumb-infra/projects/
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy on server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -e
|
||||
cd /root/dockerfiles-breadcrumb-infra/projects
|
||||
|
||||
docker load < breadcrumb-the-shire.tar.gz
|
||||
rm breadcrumb-the-shire.tar.gz
|
||||
|
||||
docker compose up -d --remove-orphans
|
||||
sleep 5
|
||||
|
||||
docker compose exec -T php php bin/console module:sync
|
||||
39
.github/workflows/qa-required.yaml
vendored
Normal file
39
.github/workflows/qa-required.yaml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: qa-required
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
qa-required:
|
||||
name: qa-required
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: breadcrumb-ci
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create .env for CI
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Start project services
|
||||
run: |
|
||||
docker compose up -d --force-recreate php db memcached
|
||||
sleep 20
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: |
|
||||
docker compose exec -T php bash -c "cd /var/www && composer install --no-interaction --prefer-dist --optimize-autoloader"
|
||||
|
||||
- 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -59,7 +59,7 @@ class AuthServicesFactory
|
||||
$this->authRepositoryFactory->createPasswordResetRepository(),
|
||||
$this->userServicesFactory->createUserPasswordService(),
|
||||
$this->createRememberMeService(),
|
||||
$this->mailServicesFactory->createMailService()
|
||||
$this->mailServicesFactory->createMailService(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class AuthServicesFactory
|
||||
$this->userServicesFactory->createUserReadRepository(),
|
||||
$this->userServicesFactory->createUserWriteRepository(),
|
||||
$this->authRepositoryFactory->createEmailVerificationRepository(),
|
||||
$this->mailServicesFactory->createMailService()
|
||||
$this->mailServicesFactory->createMailService(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,25 @@ class EmailVerificationService
|
||||
private readonly UserReadRepositoryInterface $userReadRepository,
|
||||
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
||||
private readonly EmailVerificationRepositoryInterface $emailVerificationRepository,
|
||||
private readonly MailService $mailService
|
||||
private readonly MailService $mailService,
|
||||
private readonly string $appTitle = '',
|
||||
private readonly string $appUrl = '',
|
||||
) {
|
||||
}
|
||||
|
||||
private function resolveAppTitle(): string
|
||||
{
|
||||
return $this->appTitle !== '' ? $this->appTitle : appTitle();
|
||||
}
|
||||
|
||||
private function resolveAppUrl(string $path = ''): string
|
||||
{
|
||||
if ($this->appUrl !== '') {
|
||||
return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : '');
|
||||
}
|
||||
return appUrl($path);
|
||||
}
|
||||
|
||||
public function sendVerification(int $userId, ?string $locale = null): array
|
||||
{
|
||||
$user = $this->userReadRepository->find($userId);
|
||||
@@ -55,17 +70,17 @@ class EmailVerificationService
|
||||
}
|
||||
$greeting .= ',';
|
||||
$verifyPath = Request::withLocale('verify-email', $locale);
|
||||
$verifyUrl = appUrl($verifyPath);
|
||||
$verifyUrl = $this->resolveAppUrl($verifyPath);
|
||||
$previousLocale = I18n::$locale ?? null;
|
||||
I18n::$locale = $locale;
|
||||
$subject = t('Email verification code');
|
||||
I18n::$locale = $previousLocale;
|
||||
|
||||
$vars = [
|
||||
'app_name' => appTitle(),
|
||||
'app_logo_url' => appLogoUrlAbsolute(128),
|
||||
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
|
||||
'app_name' => $this->resolveAppTitle(),
|
||||
'app_logo_url' => $this->resolveAppUrl(appLogoUrl(128)),
|
||||
'imprint_url' => $this->resolveAppUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => $this->resolveAppUrl(Request::withLocale('privacy', $locale)),
|
||||
'code' => $code,
|
||||
'expires_minutes' => self::EXPIRY_MINUTES,
|
||||
'verify_url' => $verifyUrl,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Curl;
|
||||
|
||||
class OidcHttpGateway
|
||||
{
|
||||
/**
|
||||
@@ -49,7 +47,8 @@ class OidcHttpGateway
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
$status = 0;
|
||||
$responseHeaders = [];
|
||||
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||
$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)) {
|
||||
$status = (int) $m[1];
|
||||
|
||||
@@ -20,10 +20,25 @@ class PasswordResetService
|
||||
private readonly PasswordResetRepositoryInterface $passwordResetRepository,
|
||||
private readonly UserPasswordService $userPasswordService,
|
||||
private readonly RememberMeService $rememberMeService,
|
||||
private readonly MailService $mailService
|
||||
private readonly MailService $mailService,
|
||||
private readonly string $appTitle = '',
|
||||
private readonly string $appUrl = '',
|
||||
) {
|
||||
}
|
||||
|
||||
private function resolveAppTitle(): string
|
||||
{
|
||||
return $this->appTitle !== '' ? $this->appTitle : appTitle();
|
||||
}
|
||||
|
||||
private function resolveAppUrl(string $path = ''): string
|
||||
{
|
||||
if ($this->appUrl !== '') {
|
||||
return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : '');
|
||||
}
|
||||
return appUrl($path);
|
||||
}
|
||||
|
||||
public function requestReset(string $email, ?string $locale = null): array
|
||||
{
|
||||
$email = trim($email);
|
||||
@@ -58,17 +73,17 @@ class PasswordResetService
|
||||
}
|
||||
$greeting .= ',';
|
||||
$verifyPath = Request::withLocale('password/verify', $locale);
|
||||
$verifyUrl = appUrl($verifyPath);
|
||||
$verifyUrl = $this->resolveAppUrl($verifyPath);
|
||||
$previousLocale = I18n::$locale ?? null;
|
||||
I18n::$locale = $locale;
|
||||
$subject = t('Password reset code');
|
||||
I18n::$locale = $previousLocale;
|
||||
|
||||
$vars = [
|
||||
'app_name' => appTitle(),
|
||||
'app_logo_url' => appLogoUrlAbsolute(128),
|
||||
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
|
||||
'app_name' => $this->resolveAppTitle(),
|
||||
'app_logo_url' => $this->resolveAppUrl(appLogoUrl(128)),
|
||||
'imprint_url' => $this->resolveAppUrl(Request::withLocale('imprint', $locale)),
|
||||
'privacy_url' => $this->resolveAppUrl(Request::withLocale('privacy', $locale)),
|
||||
'code' => $code,
|
||||
'expires_minutes' => self::EXPIRY_MINUTES,
|
||||
'verify_url' => $verifyUrl,
|
||||
|
||||
72
deploy/docker-compose.yml
Normal file
72
deploy/docker-compose.yml
Normal 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
41
deploy/nginx/site.conf
Normal 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
36
deploy/setup.sh
Normal 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
142
docs/howto-git-workflow.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -145,7 +145,9 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
));
|
||||
|
||||
$container->set(HandoverNotificationService::class, static fn (AppContainer $c): HandoverNotificationService => new HandoverNotificationService(
|
||||
$c->get(MailService::class)
|
||||
$c->get(MailService::class),
|
||||
appTitle(),
|
||||
appUrl(),
|
||||
));
|
||||
|
||||
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -8,9 +8,24 @@ class HandoverNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailService $mailService,
|
||||
private readonly string $appTitle = '',
|
||||
private readonly string $appUrl = '',
|
||||
) {
|
||||
}
|
||||
|
||||
private function resolveAppTitle(): string
|
||||
{
|
||||
return $this->appTitle !== '' ? $this->appTitle : appTitle();
|
||||
}
|
||||
|
||||
private function resolveAppUrl(string $path = ''): string
|
||||
{
|
||||
if ($this->appUrl !== '') {
|
||||
return rtrim($this->appUrl, '/') . ($path !== '' ? '/' . ltrim($path, '/') : '');
|
||||
}
|
||||
return appUrl($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the assignee that a handover was assigned to them.
|
||||
*
|
||||
@@ -29,7 +44,7 @@ class HandoverNotificationService
|
||||
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
|
||||
$dueDate = trim((string) ($handover['due_date'] ?? ''));
|
||||
$handoverId = (int) ($handover['id'] ?? 0);
|
||||
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
|
||||
$handoverUrl = $this->resolveAppUrl('helpdesk/handovers/edit/' . $handoverId);
|
||||
|
||||
$vars = [
|
||||
'assignee_name' => $assigneeName,
|
||||
@@ -38,10 +53,10 @@ class HandoverNotificationService
|
||||
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
|
||||
'due_date' => $dueDate !== '' ? $dueDate : '—',
|
||||
'handover_url' => $handoverUrl,
|
||||
'app_name' => appTitle(),
|
||||
'app_name' => $this->resolveAppTitle(),
|
||||
];
|
||||
|
||||
$subject = sprintf('[%s] Ihnen wurde eine Übergabe zugewiesen', appTitle());
|
||||
$subject = sprintf('[%s] Ihnen wurde eine Übergabe zugewiesen', $this->resolveAppTitle());
|
||||
|
||||
$this->mailService->sendTemplate('handover_assigned', $vars, $toEmail, $subject);
|
||||
}
|
||||
@@ -69,7 +84,7 @@ class HandoverNotificationService
|
||||
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
|
||||
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
|
||||
$handoverId = (int) ($handover['id'] ?? 0);
|
||||
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
|
||||
$handoverUrl = $this->resolveAppUrl('helpdesk/handovers/edit/' . $handoverId);
|
||||
|
||||
$vars = [
|
||||
'manager_name' => $managerName,
|
||||
@@ -77,10 +92,10 @@ class HandoverNotificationService
|
||||
'debitor_name' => $debitorName !== '' ? $debitorName : '—',
|
||||
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
|
||||
'handover_url' => $handoverUrl,
|
||||
'app_name' => appTitle(),
|
||||
'app_name' => $this->resolveAppTitle(),
|
||||
];
|
||||
|
||||
$subject = sprintf('[%s] Übergabe wurde zur Prüfung eingereicht', appTitle());
|
||||
$subject = sprintf('[%s] Übergabe wurde zur Prüfung eingereicht', $this->resolveAppTitle());
|
||||
|
||||
$this->mailService->sendTemplate('handover_review_requested', $vars, $toEmail, $subject);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
86
modules/helpdesk/pages/helpdesk/analytics-data().php
Normal file
86
modules/helpdesk/pages/helpdesk/analytics-data().php
Normal 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,
|
||||
]),
|
||||
]));
|
||||
21
modules/helpdesk/pages/helpdesk/analytics/index().php
Normal file
21
modules/helpdesk/pages/helpdesk/analytics/index().php
Normal 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')],
|
||||
];
|
||||
147
modules/helpdesk/pages/helpdesk/analytics/index(default).phtml
Normal file
147
modules/helpdesk/pages/helpdesk/analytics/index(default).phtml
Normal 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>
|
||||
@@ -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); ?>">
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -24,6 +24,10 @@ return gridFilterSchema([
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'view',
|
||||
'type' => 'hidden',
|
||||
],
|
||||
[
|
||||
'key' => 'status',
|
||||
'type' => 'select',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ class HandoverNotificationServiceTest extends TestCase
|
||||
private function createNotificationService(?MailService $mailService = null): HandoverNotificationService
|
||||
{
|
||||
return new HandoverNotificationService(
|
||||
$mailService ?? $this->createMock(MailService::class)
|
||||
$mailService ?? $this->createMock(MailService::class),
|
||||
'TestApp',
|
||||
'http://localhost',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
218
modules/helpdesk/web/js/helpdesk-analytics.js
Normal file
218
modules/helpdesk/web/js/helpdesk-analytics.js
Normal 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
120
modules/security/CLAUDE.md
Normal 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 1–5 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.
|
||||
204
modules/security/i18n/default_de.json
Normal file
204
modules/security/i18n/default_de.json
Normal 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 2–3h go-live window is relevant to the customer.": "Den Termin intern und mit dem Kunden abstimmen. Für den Kunden ist nur das 2–3-stündige Live-Stellungs-Fenster relevant.",
|
||||
"Create a 1–2 day internal blocker; invite Customer Care, Customizing and Sales.": "Einen internen Blocker-Termin (1–2 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"
|
||||
}
|
||||
204
modules/security/i18n/default_en.json
Normal file
204
modules/security/i18n/default_en.json
Normal 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 2–3h go-live window is relevant to the customer.": "Agree internally and with the customer on the check date. Only the 2–3h go-live window is relevant to the customer.",
|
||||
"Create a 1–2 day internal blocker; invite Customer Care, Customizing and Sales.": "Create a 1–2 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"
|
||||
}
|
||||
@@ -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,
|
||||
]];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 2–3h 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 1–2 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -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, '_');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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, '-');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">✓ ' . $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">✓ ' . $this->esc(t('Completed')) . '</span>'
|
||||
: '<span class="pending">' . $this->esc(t('Open')) . '</span>';
|
||||
$completedAt = trim((string) ($step['completed_at'] ?? ''));
|
||||
$when = $completedAt !== '' ? ' <span class="muted">· ' . $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}} · {{title}} · {{generated_at}}</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function esc(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
29
modules/security/migrations/002_create_security_checks.sql
Normal file
29
modules/security/migrations/002_create_security_checks.sql
Normal 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;
|
||||
@@ -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
164
modules/security/module.php
Normal 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',
|
||||
];
|
||||
47
modules/security/pages/security/checks-data().php
Normal file
47
modules/security/pages/security/checks-data().php
Normal 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);
|
||||
78
modules/security/pages/security/checks/create().php
Normal file
78
modules/security/pages/security/checks/create().php
Normal 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')],
|
||||
];
|
||||
132
modules/security/pages/security/checks/create(default).phtml
Normal file
132
modules/security/pages/security/checks/create(default).phtml
Normal 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>
|
||||
166
modules/security/pages/security/checks/edit($id).php
Normal file
166
modules/security/pages/security/checks/edit($id).php
Normal 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],
|
||||
];
|
||||
259
modules/security/pages/security/checks/edit(default).phtml
Normal file
259
modules/security/pages/security/checks/edit(default).phtml
Normal 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>
|
||||
48
modules/security/pages/security/checks/filter-schema.php
Normal file
48
modules/security/pages/security/checks/filter-schema.php
Normal 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],
|
||||
],
|
||||
],
|
||||
]);
|
||||
53
modules/security/pages/security/checks/index().php
Normal file
53
modules/security/pages/security/checks/index().php
Normal 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')],
|
||||
];
|
||||
65
modules/security/pages/security/checks/index(default).phtml
Normal file
65
modules/security/pages/security/checks/index(default).phtml
Normal 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>
|
||||
82
modules/security/pages/security/checks/report($id).php
Normal file
82
modules/security/pages/security/checks/report($id).php
Normal 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);
|
||||
47
modules/security/pages/security/customer-domains-data().php
Normal file
47
modules/security/pages/security/customer-domains-data().php
Normal 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);
|
||||
48
modules/security/pages/security/customer-search-data().php
Normal file
48
modules/security/pages/security/customer-search-data().php
Normal 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);
|
||||
84
modules/security/pages/security/report-design/index().php
Normal file
84
modules/security/pages/security/report-design/index().php
Normal 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')],
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
89
modules/security/pages/security/settings/index().php
Normal file
89
modules/security/pages/security/settings/index().php
Normal 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')],
|
||||
];
|
||||
112
modules/security/pages/security/settings/index(default).phtml
Normal file
112
modules/security/pages/security/settings/index(default).phtml
Normal 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>
|
||||
@@ -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'),
|
||||
]);
|
||||
41
modules/security/pages/security/templates-data().php
Normal file
41
modules/security/pages/security/templates-data().php
Normal 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);
|
||||
62
modules/security/pages/security/templates/create().php
Normal file
62
modules/security/pages/security/templates/create().php
Normal 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')],
|
||||
];
|
||||
@@ -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>
|
||||
93
modules/security/pages/security/templates/edit($id).php
Normal file
93
modules/security/pages/security/templates/edit($id).php
Normal 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],
|
||||
];
|
||||
@@ -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>
|
||||
41
modules/security/pages/security/templates/filter-schema.php
Normal file
41
modules/security/pages/security/templates/filter-schema.php
Normal 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],
|
||||
],
|
||||
],
|
||||
]);
|
||||
41
modules/security/pages/security/templates/index().php
Normal file
41
modules/security/pages/security/templates/index().php
Normal 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')],
|
||||
];
|
||||
@@ -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>
|
||||
89
modules/security/templates/aside-security-panel.phtml
Normal file
89
modules/security/templates/aside-security-panel.phtml
Normal 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>
|
||||
123
modules/security/templates/pdf/customer-report.phtml
Normal file
123
modules/security/templates/pdf/customer-report.phtml
Normal 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">✓ <?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">✓ <?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">· <?php e((string) $step['completed_at']); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<?php if ($appName !== ''): ?><?php e($appName); ?> · <?php endif; ?><?php e((string) ($report['title'] ?? '')); ?> · <?php e((string) ($report['generated_at'] ?? '')); ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
modules/security/templates/tech-markdown.phtml
Normal file
21
modules/security/templates/tech-markdown.phtml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user