Files
Hermes Agent 5d87e4975c Add HttpOnly cookie auth for cross-domain SPA login
Problem: Frontend JS bundle used credentials:'same-origin' which
dropped cookies on cross-domain requests. Login worked (200) but
the subsequent /auth/me check returned 401, leaving the user stuck
on the login screen.

Fix:
- AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh
- Cookie uses Domain=.fahrschultermin.de (shared between frontend
  and api subdomain), Secure, SameSite=Lax, Max-Age=8h
- JwtMiddleware reads JWT from Authorization header OR cookie
- Added AuthController::me() endpoint (was missing, caused 500)
- Logout endpoint clears the cookie
- Frontend index.php patches fetch() to use credentials:'include'
  for all /api/v1/* calls
2026-06-04 20:33:31 +02:00

565 lines
19 KiB
PHP
Executable File

<?php
/**
* Admin Controller (Platform Admin only)
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Support\Database;
use App\Support\Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
class AdminController
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
/**
* GET /api/admin/tenants
*/
public function listTenants(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$page = (int)($request->getQueryParams()['page'] ?? 1);
$limit = min((int)($request->getQueryParams()['limit'] ?? 20), 100);
$offset = ($page - 1) * $limit;
$status = $request->getQueryParams()['status'] ?? null;
$where = '';
$params = [];
if ($status) {
$where = 'WHERE status = $1';
$params[] = $status;
}
$total = Database::fetch("SELECT COUNT(*) as count FROM tenants {$where}", $params)['count'];
$tenants = Database::fetchAll(
"SELECT id, name, slug, email, status, trial_ends_at, is_self_hosted, created_at
FROM tenants {$where}
ORDER BY created_at DESC
LIMIT {$limit} OFFSET {$offset}",
$params
);
// Get module counts
foreach ($tenants as &$tenant) {
$modules = Database::fetchAll(
'SELECT m.key FROM modules m JOIN tenant_modules tm ON tm.module_id = m.id WHERE tm.tenant_id = $1 AND tm.status = $2',
[$tenant['id'], 'enabled']
);
$tenant['modules'] = array_column($modules, 'key');
$tenant['module_count'] = count($modules);
}
return Response::success($response, [
'tenants' => $tenants,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => (int)$total,
'pages' => ceil($total / $limit),
],
]);
}
/**
* POST /api/admin/tenants
*/
public function createTenant(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$name = trim($body['name'] ?? '');
$email = trim($body['email'] ?? '');
$phone = trim($body['phone'] ?? '');
$address = trim($body['address'] ?? '');
$errors = [];
if (empty($name)) $errors['name'] = 'Name is required';
if (empty($email)) $errors['email'] = 'Email is required';
if (!empty($errors)) {
return Response::validationError($response, $errors);
}
$slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $name));
$originalSlug = $slug;
$counter = 1;
while (Database::fetch('SELECT id FROM tenants WHERE slug = $1', [$slug])) {
$slug = $originalSlug . '-' . $counter++;
}
$tenantId = Database::insert('tenants', [
'name' => $name,
'slug' => $slug,
'email' => $email,
'phone' => $phone,
'address' => $address,
'status' => 'active',
]);
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$tenantId]);
return Response::success($response, $tenant, 'Tenant created', 201);
}
/**
* GET /api/admin/tenants/:id
*/
public function getTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
if (!$tenant) {
return Response::notFound($response, 'Tenant not found');
}
// Get branches
$tenant['branches'] = Database::fetchAll(
'SELECT * FROM branches WHERE tenant_id = $1 ORDER BY is_headquarters DESC, name ASC',
[$id]
);
// Get users count
$tenant['user_count'] = Database::fetch(
'SELECT COUNT(*) as count FROM users WHERE tenant_id = $1',
[$id]
)['count'];
// Get modules
$modules = Database::fetchAll(
'SELECT m.*, tm.status, tm.enabled_at FROM modules m JOIN tenant_modules tm ON tm.module_id = m.id WHERE tm.tenant_id = $1',
[$id]
);
$tenant['modules'] = $modules;
return Response::success($response, $tenant);
}
/**
* PUT /api/admin/tenants/:id
*/
public function updateTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$body = $request->getParsedBody();
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
if (!$tenant) {
return Response::notFound($response, 'Tenant not found');
}
$allowed = ['name', 'email', 'phone', 'address', 'status', 'primary_color', 'secondary_color', 'logo_url', 'custom_css'];
$data = array_intersect_key($body, array_flip($allowed));
if (!empty($data)) {
Database::update('tenants', $data, 'id = $1', [$id]);
}
$updated = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
return Response::success($response, $updated, 'Tenant updated');
}
/**
* DELETE /api/admin/tenants/:id
*/
public function deleteTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
if (!$tenant) {
return Response::notFound($response, 'Tenant not found');
}
// Check if tenant has users
$userCount = Database::fetch('SELECT COUNT(*) as count FROM users WHERE tenant_id = $1', [$id])['count'];
if ($userCount > 0) {
return Response::error($response, 'Cannot delete tenant with users. Delete users first.', 400);
}
Database::delete('tenants', 'id = $1', [$id]);
return Response::success($response, null, 'Tenant deleted');
}
/**
* POST /api/admin/tenants/:id/approve
*/
public function approveTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
if (!$tenant) {
return Response::notFound($response, 'Tenant not found');
}
if ($tenant['status'] !== 'pending_approval') {
return Response::error($response, 'Tenant is not pending approval', 400);
}
// Set to trial for 14 days
$trialEnds = date('Y-m-d H:i:s', strtotime('+14 days'));
Database::update('tenants', [
'status' => 'trial',
'trial_ends_at' => $trialEnds,
], 'id = $1', [$id]);
$updated = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
return Response::success($response, $updated, 'Tenant approved, trial started');
}
/**
* POST /api/admin/tenants/:id/suspend
*/
public function suspendTenant(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$tenant = Database::fetch('SELECT * FROM tenants WHERE id = $1', [$id]);
if (!$tenant) {
return Response::notFound($response, 'Tenant not found');
}
Database::update('tenants', ['status' => 'suspended'], 'id = $1', [$id]);
return Response::success($response, null, 'Tenant suspended');
}
/**
* GET /api/admin/tenants/:id/modules
*/
public function getTenantModules(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$modules = Database::fetchAll(
'SELECT m.*, tm.status, tm.enabled_at, tm.expires_at, tm.trial_ends_at
FROM modules m
LEFT JOIN tenant_modules tm ON tm.module_id = m.id AND tm.tenant_id = $1
ORDER BY m.name',
[$id]
);
return Response::success($response, $modules);
}
/**
* POST /api/admin/tenants/:id/modules
*/
public function enableTenantModule(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$body = $request->getParsedBody();
$moduleId = $body['module_id'] ?? '';
$expiresAt = $body['expires_at'] ?? null;
$trialEndsAt = $body['trial_ends_at'] ?? null;
if (empty($moduleId)) {
return Response::validationError($response, ['module_id' => 'Module ID is required']);
}
// Check if module exists
$module = Database::fetch('SELECT * FROM modules WHERE id = $1', [$moduleId]);
if (!$module) {
return Response::notFound($response, 'Module not found');
}
// Check if already enabled
$existing = Database::fetch(
'SELECT * FROM tenant_modules WHERE tenant_id = $1 AND module_id = $2',
[$id, $moduleId]
);
if ($existing) {
return Response::error($response, 'Module already enabled for this tenant', 409);
}
Database::insert('tenant_modules', [
'tenant_id' => $id,
'module_id' => $moduleId,
'status' => 'enabled',
'expires_at' => $expiresAt,
'trial_ends_at' => $trialEndsAt,
]);
return Response::success($response, null, 'Module enabled', 201);
}
/**
* DELETE /api/admin/tenants/:id/modules/:moduleId
*/
public function disableTenantModule(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenantId = $args['id'];
$moduleId = $args['moduleId'];
Database::delete('tenant_modules', 'tenant_id = $1 AND module_id = $2', [$tenantId, $moduleId]);
return Response::success($response, null, 'Module disabled');
}
/**
* GET /api/admin/platform-modules
*/
public function listPlatformModules(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$modules = Database::fetchAll('SELECT * FROM modules ORDER BY name');
return Response::success($response, $modules);
}
/**
* GET /api/admin/audit-logs
*/
public function listAuditLogs(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$page = (int)($request->getQueryParams()['page'] ?? 1);
$limit = min((int)($request->getQueryParams()['limit'] ?? 50), 100);
$offset = ($page - 1) * $limit;
$tenantId = $request->getQueryParams()['tenant_id'] ?? null;
$action = $request->getQueryParams()['action'] ?? null;
$entityType = $request->getQueryParams()['entity_type'] ?? null;
$where = [];
$params = [];
$paramIndex = 1;
if ($tenantId) {
$where[] = "tenant_id = \${$paramIndex}";
$params[] = $tenantId;
$paramIndex++;
}
if ($action) {
$where[] = "action = \${$paramIndex}";
$params[] = $action;
$paramIndex++;
}
if ($entityType) {
$where[] = "entity_type = \${$paramIndex}";
$params[] = $entityType;
$paramIndex++;
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$total = Database::fetch("SELECT COUNT(*) as count FROM audit_logs {$whereClause}", $params)['count'];
$logs = Database::fetchAll(
"SELECT al.*,
pa.name as admin_name, pa.email as admin_email,
u.first_name, u.last_name, u.email as user_email,
t.name as tenant_name
FROM audit_logs al
LEFT JOIN platform_admins pa ON al.user_id = al.tenant_id IS NULL AND pa.id = al.user_id
LEFT JOIN users u ON al.tenant_id IS NOT NULL AND u.id = al.user_id
LEFT JOIN tenants t ON t.id = al.tenant_id
{$whereClause}
ORDER BY al.created_at DESC
LIMIT {$limit} OFFSET {$offset}",
$params
);
return Response::success($response, [
'logs' => $logs,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => (int)$total,
'pages' => ceil($total / $limit),
],
]);
}
/**
* GET /api/admin/invoices
*/
public function listInvoices(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$page = (int)($request->getQueryParams()['page'] ?? 1);
$limit = min((int)($request->getQueryParams()['limit'] ?? 20), 100);
$offset = ($page - 1) * $limit;
$status = $request->getQueryParams()['status'] ?? null;
$tenantId = $request->getQueryParams()['tenant_id'] ?? null;
$where = [];
$params = [];
$paramIndex = 1;
if ($status) {
$where[] = "i.status = \${$paramIndex}";
$params[] = $status;
$paramIndex++;
}
if ($tenantId) {
$where[] = "i.tenant_id = \${$paramIndex}";
$params[] = $tenantId;
$paramIndex++;
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$total = Database::fetch("SELECT COUNT(*) as count FROM invoices i {$whereClause}", $params)['count'];
$invoices = Database::fetchAll(
"SELECT i.*, t.name as tenant_name, t.slug as tenant_slug
FROM invoices i
JOIN tenants t ON t.id = i.tenant_id
{$whereClause}
ORDER BY i.created_at DESC
LIMIT {$limit} OFFSET {$offset}",
$params
);
return Response::success($response, [
'invoices' => $invoices,
'pagination' => [
'page' => $page,
'limit' => $limit,
'total' => (int)$total,
'pages' => ceil($total / $limit),
],
]);
}
/**
* PUT /api/admin/invoices/:id/mark-paid
*/
public function markInvoicePaid(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$invoice = Database::fetch('SELECT * FROM invoices WHERE id = $1', [$id]);
if (!$invoice) {
return Response::notFound($response, 'Invoice not found');
}
Database::update('invoices', [
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
], 'id = $1', [$id]);
$updated = Database::fetch('SELECT * FROM invoices WHERE id = $1', [$id]);
return Response::success($response, $updated, 'Invoice marked as paid');
}
/**
* GET /api/admin/platform-admins
*/
public function listPlatformAdmins(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$admins = Database::fetchAll(
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins ORDER BY created_at DESC'
);
return Response::success($response, $admins);
}
/**
* POST /api/admin/platform-admins
*/
public function createPlatformAdmin(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
$name = trim($body['name'] ?? '');
$password = $body['password'] ?? '';
$role = $body['role'] ?? 'support';
$errors = [];
if (empty($email)) $errors['email'] = 'Email is required';
if (empty($name)) $errors['name'] = 'Name is required';
if (empty($password)) $errors['password'] = 'Password is required';
elseif (strlen($password) < 8) $errors['password'] = 'Password must be at least 8 characters';
if (!empty($errors)) {
return Response::validationError($response, $errors);
}
// Check if email exists
if (Database::fetch('SELECT id FROM platform_admins WHERE email = $1', [$email])) {
return Response::error($response, 'Email already exists', 409);
}
$id = Database::insert('platform_admins', [
'email' => $email,
'name' => $name,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'role' => $role,
]);
$admin = Database::fetch(
'SELECT id, email, name, role, created_at FROM platform_admins WHERE id = $1',
[$id]
);
return Response::success($response, $admin, 'Platform admin created', 201);
}
/**
* PUT /api/admin/platform-admins/:id
*/
public function updatePlatformAdmin(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$body = $request->getParsedBody();
$allowed = ['name', 'role'];
if (!empty($body['password'])) {
$allowed[] = 'password_hash';
$body['password_hash'] = password_hash($body['password'], PASSWORD_DEFAULT);
}
$data = array_intersect_key($body, array_flip($allowed));
if (!empty($data)) {
Database::update('platform_admins', $data, 'id = $1', [$id]);
}
$admin = Database::fetch(
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins WHERE id = $1',
[$id]
);
return Response::success($response, $admin, 'Platform admin updated');
}
/**
* DELETE /api/admin/platform-admins/:id
*/
public function deletePlatformAdmin(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$id = $args['id'];
$auth = $request->getAttribute('auth');
if ($auth['id'] === $id) {
return Response::error($response, 'Cannot delete yourself', 400);
}
Database::delete('platform_admins', 'id = $1', [$id]);
return Response::success($response, null, 'Platform admin deleted');
}
}