Phase 1: API + Frontend Skelett

This commit is contained in:
Hermes Agent
2026-05-18 23:08:54 +02:00
parent aa9580c061
commit 4f0ab5c518
29 changed files with 3436 additions and 1 deletions

View File

@@ -0,0 +1,565 @@
<?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');
}
}

View File

@@ -0,0 +1,317 @@
<?php
/**
* Auth Controller
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Support\Database;
use App\Support\Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
class AuthController
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
/**
* POST /api/auth/login
*/
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
if (empty($email) || empty($password)) {
return Response::validationError($response, [
'email' => 'Email is required',
'password' => 'Password is required',
]);
}
// Check if it's a platform admin login
$admin = Database::fetch(
'SELECT * FROM platform_admins WHERE email = $1',
[$email]
);
if ($admin && password_verify($password, $admin['password_hash'])) {
return $this->createTokenResponse($response, $admin, 'platform_admin');
}
// Check if it's a tenant user login
$user = Database::fetch(
'SELECT u.*, t.status as tenant_status
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.email = $1',
[$email]
);
if ($user && password_verify($password, $user['password_hash'])) {
if ($user['tenant_status'] !== 'active') {
return Response::error($response, 'Account is not active', 403);
}
if ($user['status'] !== 'active') {
return Response::error($response, 'User is inactive', 403);
}
// Update last login
Database::update('users', ['last_login_at' => 'NOW()'], 'id = $1', [$user['id']]);
return $this->createTokenResponse($response, $user, 'user');
}
return Response::error($response, 'Invalid credentials', 401);
}
/**
* POST /api/auth/register
*/
public function register(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$name = trim($body['name'] ?? '');
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
$phone = trim($body['phone'] ?? '');
$address = trim($body['address'] ?? '');
// Validation
$errors = [];
if (empty($name)) $errors['name'] = 'Name is required';
if (empty($email)) $errors['email'] = 'Email is required';
elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors['email'] = 'Invalid email format';
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 already exists
$exists = Database::fetch(
'SELECT id FROM tenants WHERE email = $1',
[$email]
);
if ($exists) {
return Response::error($response, 'Email already registered', 409);
}
// Generate slug from name
$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++;
}
// Create tenant
$tenantId = Database::insert('tenants', [
'name' => $name,
'slug' => $slug,
'email' => $email,
'phone' => $phone,
'address' => $address,
'status' => 'pending_approval',
]);
// Create first user (admin of the tenant)
$userId = Database::insert('users', [
'tenant_id' => $tenantId,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'first_name' => $name,
'last_name' => '',
'user_type' => 'admin',
'status' => 'pending', // Needs email verification first
]);
// Enable default modules (office, calendar, messenger)
$defaultModules = Database::fetchAll('SELECT id FROM modules WHERE key IN (\'office\', \'calendar\', \'messenger\')');
foreach ($defaultModules as $module) {
Database::insert('tenant_modules', [
'tenant_id' => $tenantId,
'module_id' => $module['id'],
'status' => 'enabled',
]);
}
return Response::success($response, [
'tenant_id' => $tenantId,
'user_id' => $userId,
'message' => 'Registration successful. Pending admin approval.',
], 'Registration successful', 201);
}
/**
* POST /api/auth/verify-email
*/
public function verifyEmail(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$token = $body['token'] ?? '';
if (empty($token)) {
return Response::validationError($response, ['token' => 'Token is required']);
}
// TODO: Verify token and activate user
return Response::success($response, null, 'Email verified successfully');
}
/**
* GET /api/auth/me
*/
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$auth = $request->getAttribute('auth');
if ($auth['type'] === 'platform_admin') {
$data = Database::fetch(
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins WHERE id = $1',
[$auth['id']]
);
$data['type'] = 'platform_admin';
} else {
$data = Database::fetch(
'SELECT u.id, u.email, u.first_name, u.last_name, u.user_type, u.status, u.avatar_url, u.last_login_at,
t.id as tenant_id, t.name as tenant_name, t.slug as tenant_slug, t.status as tenant_status
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.id = $1',
[$auth['id']]
);
$data['type'] = 'user';
}
return Response::success($response, $data);
}
/**
* POST /api/auth/refresh
*/
public function refresh(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$refreshToken = $body['refresh_token'] ?? '';
if (empty($refreshToken)) {
return Response::validationError($response, ['refresh_token' => 'Refresh token is required']);
}
try {
$decoded = JWT::decode($refreshToken, new Key($this->config['jwt']['secret'], 'HS256'));
if ($decoded->type !== 'refresh') {
return Response::error($response, 'Invalid token type', 401);
}
// Get user/admin
if ($decoded->user_type === 'platform_admin') {
$user = Database::fetch('SELECT * FROM platform_admins WHERE id = $1', [$decoded->sub]);
} else {
$user = Database::fetch('SELECT * FROM users WHERE id = $1', [$decoded->sub]);
}
if (!$user) {
return Response::error($response, 'User not found', 401);
}
return $this->createTokenResponse($response, $user, $decoded->user_type);
} catch (\Exception $e) {
return Response::error($response, 'Invalid refresh token', 401);
}
}
/**
* POST /api/auth/logout
*/
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
// JWT is stateless, so logout is handled client-side
// Here we could blacklist the token if needed
return Response::success($response, null, 'Logged out successfully');
}
/**
* POST /api/auth/forgot-password
*/
public function forgotPassword(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
if (empty($email)) {
return Response::validationError($response, ['email' => 'Email is required']);
}
// Check if email exists
$admin = Database::fetch('SELECT id FROM platform_admins WHERE email = $1', [$email]);
$user = Database::fetch('SELECT id FROM users WHERE email = $1', [$email]);
// Always return success to prevent email enumeration
if (!$admin && !$user) {
return Response::success($response, null, 'If the email exists, a reset link has been sent');
}
// TODO: Generate reset token, send email
return Response::success($response, null, 'If the email exists, a reset link has been sent');
}
/**
* Create JWT token response
*/
private function createTokenResponse(ResponseInterface $response, array $user, string $type): ResponseInterface
{
$now = time();
$expiry = $this->config['jwt']['expiry'];
$refreshExpiry = $this->config['jwt']['refresh_expiry'];
$payload = [
'iss' => 'fahrschuldesk',
'sub' => $user['id'],
'iat' => $now,
'exp' => $now + $expiry,
'type' => 'access',
'user_type' => $type,
];
$refreshPayload = [
'iss' => 'fahrschuldesk',
'sub' => $user['id'],
'iat' => $now,
'exp' => $now + $refreshExpiry,
'type' => 'refresh',
'user_type' => $type,
];
$accessToken = JWT::encode($payload, $this->config['jwt']['secret'], 'HS256');
$refreshToken = JWT::encode($refreshPayload, $this->config['jwt']['secret'], 'HS256');
return Response::json($response, [
'success' => true,
'data' => [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $expiry,
],
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* JWT Authentication Middleware
*/
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Support\Database;
use App\Support\Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;
class AuthMiddleware
{
private string $jwtSecret;
public function __construct(string $jwtSecret)
{
$this->jwtSecret = $jwtSecret;
}
/**
* Validate JWT token and attach user to request
*/
public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$authHeader = $request->getHeaderLine('Authorization');
if (empty($authHeader) || !str_starts_with($authHeader, 'Bearer ')) {
return Response::unauthorized($response ?? $handler->handle($request), 'No token provided');
}
$token = substr($authHeader, 7);
try {
$decoded = JWT::decode($token, new Key($this->jwtSecret, 'HS256'));
if ($decoded->type !== 'access') {
return Response::unauthorized($handler->handle($request), 'Invalid token type');
}
// Attach auth info to request
$request = $request->withAttribute('auth', [
'id' => $decoded->sub,
'type' => $decoded->user_type,
'token' => $decoded,
]);
return $handler->handle($request);
} catch (\Firebase\JWT\ExpiredException $e) {
return Response::unauthorized($handler->handle($request), 'Token expired');
} catch (\Exception $e) {
return Response::unauthorized($handler->handle($request), 'Invalid token');
}
}
/**
* Validate that the user is a platform admin
*/
public static function platformAdmin(ServerRequestInterface $request): ?ResponseInterface
{
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'platform_admin') {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'Platform admin access required');
}
return null; // OK
}
/**
* Validate that the user is a tenant user (any type)
*/
public static function tenantUser(ServerRequestInterface $request): ?ResponseInterface
{
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'user') {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'Tenant user access required');
}
return null; // OK
}
/**
* Validate that the user has a specific user type
*/
public static function requireUserType(ServerRequestInterface $request, array $types): ?ResponseInterface
{
$auth = $request->getAttribute('auth');
if (!$auth || $auth['type'] !== 'user') {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'User access required');
}
$user = Database::fetch('SELECT user_type FROM users WHERE id = $1', [$auth['id']]);
if (!$user || !in_array($user['user_type'], $types)) {
$response = new \Slim\Psr7\Response();
return Response::forbidden($response, 'Insufficient permissions');
}
return null; // OK
}
}