diff --git a/config/database.example b/config/database.example new file mode 100644 index 0000000..dd725ff --- /dev/null +++ b/config/database.example @@ -0,0 +1,12 @@ +# PostgreSQL Database (fahrschuldesk) +# Zugriff nur über SSH-Tunnel via fahrschuldesk.de + +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5434 +DB_DATABASE=fahrschuldesk +DB_USERNAME=fahrschuldesk_api +DB_PASSWORD=E8xP2mKjQ9nRtWsLvZ3bVc5YyH1sDe7f + +# SSH Tunnel (aufbauen vor DB-Zugriff): +# ssh -p 2225 -f -L 5434:10.255.30.11:5433 fahrschuldesk@fahrschuldesk.de sleep 60 diff --git a/www/api.fahrschuldesk.de/app/Http/Controllers/AdminController.php b/www/api.fahrschuldesk.de/app/Http/Controllers/AdminController.php new file mode 100644 index 0000000..40d2e7d --- /dev/null +++ b/www/api.fahrschuldesk.de/app/Http/Controllers/AdminController.php @@ -0,0 +1,565 @@ +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'); + } +} \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/app/Http/Controllers/AuthController.php b/www/api.fahrschuldesk.de/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..68c78cd --- /dev/null +++ b/www/api.fahrschuldesk.de/app/Http/Controllers/AuthController.php @@ -0,0 +1,317 @@ +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, + ], + ]); + } +} \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/app/Http/Middleware/AuthMiddleware.php b/www/api.fahrschuldesk.de/app/Http/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..ed88829 --- /dev/null +++ b/www/api.fahrschuldesk.de/app/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,113 @@ +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 + } +} \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/app/Support/Database.php b/www/api.fahrschuldesk.de/app/Support/Database.php new file mode 100644 index 0000000..17e4efb --- /dev/null +++ b/www/api.fahrschuldesk.de/app/Support/Database.php @@ -0,0 +1,104 @@ +prepare($sql); + $stmt->execute($params); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function fetchAll(string $sql, array $params = []): array + { + $stmt = self::getConnection()->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(); + } + + public static function insert(string $table, array $data): string + { + $columns = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders}) RETURNING id"; + $stmt = self::getConnection()->prepare($sql); + $stmt->execute(array_values($data)); + + $result = $stmt->fetch(); + return $result['id'] ?? ''; + } + + public static function update(string $table, array $data, string $where, array $whereParams = []): int + { + $set = implode(' = ?, ', array_keys($data)) . ' = ?'; + $sql = "UPDATE {$table} SET {$set} WHERE {$where}"; + + $stmt = self::getConnection()->prepare($sql); + $stmt->execute([...array_values($data), ...$whereParams]); + + return $stmt->rowCount(); + } + + public static function delete(string $table, string $where, array $whereParams = []): int + { + $sql = "DELETE FROM {$table} WHERE {$where}"; + $stmt = self::getConnection()->prepare($sql); + $stmt->execute($whereParams); + + return $stmt->rowCount(); + } + + public static function query(string $sql, array $params = []): PDOStatement + { + $stmt = self::getConnection()->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + public static function beginTransaction(): bool + { + return self::getConnection()->beginTransaction(); + } + + public static function commit(): bool + { + return self::getConnection()->commit(); + } + + public static function rollback(): bool + { + return self::getConnection()->rollBack(); + } + + public static function lastInsertId(): string + { + return self::getConnection()->lastInsertId(); + } +} \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/app/Support/Response.php b/www/api.fahrschuldesk.de/app/Support/Response.php new file mode 100644 index 0000000..d21b022 --- /dev/null +++ b/www/api.fahrschuldesk.de/app/Support/Response.php @@ -0,0 +1,77 @@ +getBody()->write(json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + return $response + ->withStatus($status) + ->withHeader('Content-Type', 'application/json'); + } + + public static function success( + ResponseInterface $response, + mixed $data = null, + string $message = 'OK', + int $status = 200 + ): ResponseInterface { + return self::json($response, [ + 'success' => true, + 'message' => $message, + 'data' => $data, + ], $status); + } + + public static function error( + ResponseInterface $response, + string $message, + int $status = 400, + array $errors = [] + ): ResponseInterface { + $body = [ + 'success' => false, + 'message' => $message, + ]; + + if (!empty($errors)) { + $body['errors'] = $errors; + } + + return self::json($response, $body, $status); + } + + public static function notFound(ResponseInterface $response, string $message = 'Not found'): ResponseInterface + { + return self::error($response, $message, 404); + } + + public static function unauthorized(ResponseInterface $response, string $message = 'Unauthorized'): ResponseInterface + { + return self::error($response, $message, 401); + } + + public static function forbidden(ResponseInterface $response, string $message = 'Forbidden'): ResponseInterface + { + return self::error($response, $message, 403); + } + + public static function validationError( + ResponseInterface $response, + array $errors + ): ResponseInterface { + return self::error($response, 'Validation failed', 422, $errors); + } +} \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/bin/migrate.php b/www/api.fahrschuldesk.de/bin/migrate.php new file mode 100644 index 0000000..571915a --- /dev/null +++ b/www/api.fahrschuldesk.de/bin/migrate.php @@ -0,0 +1,87 @@ + PDO::ERRMODE_EXCEPTION, + ]); + echo "✓ Connected to database\n\n"; +} catch (PDOException $e) { + echo "✗ Database connection failed: " . $e->getMessage() . "\n"; + exit(1); +} + +// Get all migration files +$migrationsPath = __DIR__ . '/../database/migrations'; +$files = glob($migrationsPath . '/*.sql'); + +if (empty($files)) { + echo "No migration files found.\n"; + exit(0); +} + +sort($files); + +echo "Found " . count($files) . " migration(s)\n\n"; + +// Create migrations table if not exists +$pdo->exec(" + CREATE TABLE IF NOT EXISTS migrations ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + executed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) +"); + +// Get executed migrations +$executed = $pdo->query("SELECT name FROM migrations")->fetchAll(PDO::FETCH_COLUMN); + +// Run pending migrations +foreach ($files as $file) { + $filename = basename($file); + + if (in_array($filename, $executed)) { + echo "○ {$filename} (already executed, skipping)\n"; + continue; + } + + echo "→ Running: {$filename}\n"; + + try { + $sql = file_get_contents($file); + $pdo->exec($sql); + + $stmt = $pdo->prepare("INSERT INTO migrations (name) VALUES (:name)"); + $stmt->execute(['name' => $filename]); + + echo "✓ {$filename} completed\n"; + } catch (PDOException $e) { + echo "✗ {$filename} failed: " . $e->getMessage() . "\n"; + exit(1); + } +} + +echo "\n==============================\n"; +echo "All migrations completed!\n"; \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/bootstrap.php b/www/api.fahrschuldesk.de/bootstrap.php new file mode 100644 index 0000000..237c395 --- /dev/null +++ b/www/api.fahrschuldesk.de/bootstrap.php @@ -0,0 +1,52 @@ +set('db', function () use ($config) { + $dsn = sprintf( + 'pgsql:host=%s;port=%d;dbname=%s', + $config['db']['host'], + $config['db']['port'], + $config['db']['database'] + ); + + try { + $pdo = new PDO($dsn, $config['db']['username'], $config['db']['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return $pdo; + } catch (PDOException $e) { + throw new \RuntimeException('Database connection failed: ' . $e->getMessage()); + } +}); + +// JWT Secret +$container->set('jwt_secret', $config['jwt']['secret']); + +// Return container +return $container; \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/composer.json b/www/api.fahrschuldesk.de/composer.json new file mode 100644 index 0000000..99d5bd6 --- /dev/null +++ b/www/api.fahrschuldesk.de/composer.json @@ -0,0 +1,22 @@ +{ + "name": "fahrschuldesk/api", + "description": "fahrschuldesk REST API", + "type": "project", + "require": { + "php": "^8.2", + "slim/slim": "^4.12", + "slim/psr7": "^1.6", + "php-di/php-di": "^7.0", + "firebase/php-jwt": "^6.10", + "passwordlib/passwordlib": "^1.0", + "ramsey/uuid": "^4.7" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "scripts": { + "migrate": "php bin/migrate.php" + } +} \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/config/app.php b/www/api.fahrschuldesk.de/config/app.php new file mode 100644 index 0000000..8f063be --- /dev/null +++ b/www/api.fahrschuldesk.de/config/app.php @@ -0,0 +1,48 @@ + [ + 'name' => 'fahrschuldesk API', + 'env' => getenv('APP_ENV') ?: 'production', + 'debug' => getenv('APP_DEBUG') === 'true', + 'timezone' => 'Europe/Berlin', + ], + + 'db' => [ + 'host' => getenv('DB_HOST') ?: '10.255.30.11', + 'port' => (int)(getenv('DB_PORT') ?: 5433), + 'database' => getenv('DB_DATABASE') ?: 'fahrschuldesk', + 'username' => getenv('DB_USERNAME') ?: 'fahrschuldesk_api', + 'password' => getenv('DB_PASSWORD') ?: '', + ], + + 'jwt' => [ + 'secret' => getenv('JWT_SECRET') ?: 'change-this-in-production', + 'expiry' => 3600 * 24, // 24 hours + 'refresh_expiry' => 3600 * 24 * 30, // 30 days + ], + + 'storage' => [ + 'path' => getenv('STORAGE_PATH') ?: '/srv/web/fahrschuldesk/storage', + ], + + 'mail' => [ + 'driver' => getenv('MAIL_DRIVER') ?: 'log', // log, smtp + 'from' => [ + 'address' => 'noreply@fahrschuldesk.de', + 'name' => 'fahrschuldesk', + ], + ], + + 'cors' => [ + 'allowed_origins' => explode(',', getenv('CORS_ORIGINS') ?: 'https://fahrschuldesk.de,https://www.fahrschuldesk.de'), + 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + 'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'], + 'allow_credentials' => true, + ], +]; \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/database/migrations/001_initial_schema.sql b/www/api.fahrschuldesk.de/database/migrations/001_initial_schema.sql new file mode 100644 index 0000000..84a965a --- /dev/null +++ b/www/api.fahrschuldesk.de/database/migrations/001_initial_schema.sql @@ -0,0 +1,370 @@ +-- Migration: 001_initial_schema.sql +-- Created: 2024 + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ============================================ +-- PLATFORM ADMINS (internal team) +-- ============================================ +CREATE TABLE platform_admins ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'support' CHECK (role IN ('super_admin', 'support', 'sales')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_platform_admins_email ON platform_admins(email); + +-- ============================================ +-- MODULES (global module definitions) +-- ============================================ +CREATE TABLE modules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + key VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + monthly_price INTEGER NOT NULL DEFAULT 0, -- cent + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default modules +INSERT INTO modules (key, name, description, monthly_price) VALUES + ('office', 'Office', 'Kundenverwaltung, Fahrzeugverwaltung, Berichte', 0), + ('calendar', 'Kalender', 'Terminplanung (Integration fahrschultermin.de)', 0), + ('messenger', 'Messenger', 'WhatsApp-ähnlicher Messenger', 0), + ('learning_blank', 'Lernsoftware (Blanko)', 'Eigene Inhalte erstellen', 0), + ('learning_premium', 'Lernsoftware (Premium)', 'Inkl. vorgefertigter Lerninhalte', 0); + +-- ============================================ +-- PACKAGES (pricing bundles) +-- ============================================ +CREATE TABLE packages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + key VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT, + monthly_price INTEGER NOT NULL DEFAULT 0, -- cent + modules_included JSONB DEFAULT '[]', + max_users INTEGER DEFAULT 5, + max_branches INTEGER DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default packages +INSERT INTO packages (key, name, description, monthly_price, modules_included, max_users, max_branches) VALUES + ('starter', 'Starter', 'Basic-Paket für kleine Fahrschulen', 4900, '["office", "calendar", "messenger"]', 5, 1), + ('professional', 'Professional', 'Für mittlere Fahrschulen', 9900, '["office", "calendar", "messenger", "learning_blank"]', 20, 3), + ('premium', 'Premium', 'Alle Funktionen inkl. Premium-Lerninhalte', 14900, '["office", "calendar", "messenger", "learning_premium"]', 100, 10); + +-- ============================================ +-- TENANTS (fahrschulen / customers) +-- ============================================ +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + phone VARCHAR(100), + address TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'pending_approval' CHECK (status IN ('pending_approval', 'active', 'suspended', 'trial')), + trial_ends_at TIMESTAMP WITH TIME ZONE, + is_self_hosted BOOLEAN DEFAULT FALSE, + self_hosted_license_key VARCHAR(255), + -- Branding + logo_url VARCHAR(500), + primary_color VARCHAR(7) DEFAULT '#2563eb', + secondary_color VARCHAR(7) DEFAULT '#64748b', + custom_css TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status); +CREATE INDEX idx_tenants_email ON tenants(email); + +-- ============================================ +-- BRANCHES (filialen) +-- ============================================ +CREATE TABLE branches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + address TEXT, + phone VARCHAR(100), + email VARCHAR(255), + opening_hours JSONB DEFAULT '{}', + is_headquarters BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_branches_tenant ON branches(tenant_id); + +-- ============================================ +-- USERS (tenant users) +-- ============================================ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + branch_id UUID REFERENCES branches(id) ON DELETE SET NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + user_type VARCHAR(50) NOT NULL DEFAULT 'student' CHECK (user_type IN ('admin', 'office', 'instructor', 'lecturer', 'student')), + status VARCHAR(50) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'pending')), + avatar_url VARCHAR(500), + phone VARCHAR(100), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login_at TIMESTAMP WITH TIME ZONE, + UNIQUE(tenant_id, email) +); + +CREATE INDEX idx_users_tenant ON users(tenant_id); +CREATE INDEX idx_users_branch ON users(branch_id); +CREATE INDEX idx_users_email ON users(email); + +-- ============================================ +-- TENANT MODULES (module activation per tenant) +-- ============================================ +CREATE TABLE tenant_modules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE RESTRICT, + status VARCHAR(50) NOT NULL DEFAULT 'enabled' CHECK (status IN ('enabled', 'disabled')), + enabled_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE, + trial_ends_at TIMESTAMP WITH TIME ZONE, + UNIQUE(tenant_id, module_id) +); + +CREATE INDEX idx_tenant_modules_tenant ON tenant_modules(tenant_id); + +-- ============================================ +-- TENANT PACKAGES (active package per tenant) +-- ============================================ +CREATE TABLE tenant_packages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + package_id UUID NOT NULL REFERENCES packages(id) ON DELETE RESTRICT, + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP WITH TIME ZONE, + UNIQUE(tenant_id) +); + +CREATE INDEX idx_tenant_packages_tenant ON tenant_packages(tenant_id); + +-- ============================================ +-- LEARNING CONTENTS +-- ============================================ +CREATE TABLE learning_contents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = platform global + category VARCHAR(255) NOT NULL, + title VARCHAR(500) NOT NULL, + description TEXT, + content_type VARCHAR(50) NOT NULL CHECK (content_type IN ('video', 'document', 'quiz', 'exercise')), + content_url VARCHAR(500), + thumbnail_url VARCHAR(500), + duration_minutes INTEGER, + is_premium BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_learning_contents_tenant ON learning_contents(tenant_id); +CREATE INDEX idx_learning_contents_category ON learning_contents(category); + +-- ============================================ +-- STUDENT PROGRESS +-- ============================================ +CREATE TABLE student_progress ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content_id UUID NOT NULL REFERENCES learning_contents(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')), + completed_at TIMESTAMP WITH TIME ZONE, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, content_id) +); + +CREATE INDEX idx_student_progress_user ON student_progress(user_id); +CREATE INDEX idx_student_progress_content ON student_progress(content_id); + +-- ============================================ +-- AUDIT LOGS +-- ============================================ +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = platform admin action + user_id UUID, -- can be NULL if system action + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id UUID, + old_values JSONB, + new_values JSONB, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id); +CREATE INDEX idx_audit_logs_created ON audit_logs(created_at); +CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id); + +-- ============================================ +-- INVOICES +-- ============================================ +CREATE TABLE invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + invoice_number VARCHAR(100) NOT NULL UNIQUE, + amount INTEGER NOT NULL, -- cent + vat_amount INTEGER NOT NULL DEFAULT 0, + total_amount INTEGER NOT NULL, + currency VARCHAR(3) DEFAULT 'EUR', + status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('draft', 'pending', 'paid', 'overdue', 'cancelled')), + billing_period_start DATE, + billing_period_end DATE, + due_date DATE, + paid_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_invoices_tenant ON invoices(tenant_id); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_due_date ON invoices(due_date); + +CREATE TABLE invoice_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + description TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price INTEGER NOT NULL, -- cent + total_price INTEGER NOT NULL +); + +CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id); + +-- ============================================ +-- NOTIFICATIONS +-- ============================================ +CREATE TABLE notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + key VARCHAR(100) NOT NULL UNIQUE, + subject VARCHAR(500) NOT NULL, + body_html TEXT, + body_text TEXT, + variables JSONB DEFAULT '[]', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + template_key VARCHAR(100), + subject VARCHAR(500) NOT NULL, + body TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), + sent_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notifications_user ON notifications(user_id); +CREATE INDEX idx_notifications_tenant ON notifications(tenant_id); + +-- ============================================ +-- FILES (DKS) +-- ============================================ +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id UUID, + file_type VARCHAR(100) NOT NULL, + original_filename VARCHAR(500) NOT NULL, + storage_path VARCHAR(1000) NOT NULL, + mime_type VARCHAR(255), + size_bytes BIGINT, + expires_at TIMESTAMP WITH TIME ZONE, -- for temp files + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_files_tenant ON files(tenant_id); +CREATE INDEX idx_files_entity ON files(entity_type, entity_id); +CREATE INDEX idx_files_user ON files(user_id); + +-- ============================================ +-- MESSENGER +-- ============================================ +CREATE TABLE conversations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL DEFAULT 'direct' CHECK (type IN ('direct', 'group')), + name VARCHAR(255), -- only for groups + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_conversations_tenant ON conversations(tenant_id); + +CREATE TABLE conversation_participants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_read_at TIMESTAMP WITH TIME ZONE, + UNIQUE(conversation_id, user_id) +); + +CREATE INDEX idx_conversation_participants_conversation ON conversation_participants(conversation_id); +CREATE INDEX idx_conversation_participants_user ON conversation_participants(user_id); + +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + is_edited BOOLEAN DEFAULT FALSE, + is_deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_messages_conversation ON messages(conversation_id); +CREATE INDEX idx_messages_created ON messages(created_at); + +-- ============================================ +-- UPDATE TIMESTAMP TRIGGER +-- ============================================ +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_platform_admins_updated_at BEFORE UPDATE ON platform_admins FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_tenants_updated_at BEFORE UPDATE ON tenants FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_branches_updated_at BEFORE UPDATE ON branches FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_learning_contents_updated_at BEFORE UPDATE ON learning_contents FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_student_progress_updated_at BEFORE UPDATE ON student_progress FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_invoices_updated_at BEFORE UPDATE ON invoices FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_conversations_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at(); +CREATE TRIGGER tr_messages_updated_at BEFORE UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_updated_at(); \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/public/index.php b/www/api.fahrschuldesk.de/public/index.php index 959015f..b4785a3 100644 --- a/www/api.fahrschuldesk.de/public/index.php +++ b/www/api.fahrschuldesk.de/public/index.php @@ -1,2 +1,75 @@ getContainer(); + +// Database connection for SSH tunnel +$dsn = sprintf( + 'pgsql:host=%s;port=%d;dbname=%s', + $config['db']['host'], + $config['db']['port'], + $config['db']['database'] +); + +try { + $pdo = new PDO($dsn, $config['db']['username'], $config['db']['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + Database::setConnection($pdo); +} catch (PDOException $e) { + die('Database connection failed: ' . $e->getMessage()); +} + +// CORS Middleware +$app->add(function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($config) { + $origin = $request->getHeaderLine('Origin'); + $allowed = $config['cors']['allowed_origins']; + + if (in_array('*', $allowed) || in_array($origin, $allowed)) { + return $handler->handle($request) + ->withHeader('Access-Control-Allow-Origin', $origin ?: '*') + ->withHeader('Access-Control-Allow-Methods', implode(', ', $config['cors']['allowed_methods'])) + ->withHeader('Access-Control-Allow-Headers', implode(', ', $config['cors']['allowed_headers'])) + ->withHeader('Access-Control-Allow-Credentials', 'true'); + } + + return $handler->handle($request); +}); + +// Error middleware +$displayErrors = $config['app']['debug']; +$app->addErrorMiddleware($displayErrors, true, true); + +// Import routes +require __DIR__ . '/../routes/api.php'; + +// Run +$app->run(); \ No newline at end of file diff --git a/www/api.fahrschuldesk.de/routes/api.php b/www/api.fahrschuldesk.de/routes/api.php new file mode 100644 index 0000000..6f28a52 --- /dev/null +++ b/www/api.fahrschuldesk.de/routes/api.php @@ -0,0 +1,208 @@ +unauthorized($response, $message); +}; + +// Helper to create admin-only error response +$adminOnly = function (ServerRequestInterface $request) use ($unauthorized): ?ResponseInterface { + $auth = $request->getAttribute('auth'); + if (!$auth || $auth['type'] !== 'platform_admin') { + return $unauthorized('Platform admin access required'); + } + return null; +}; + +// Helper to create tenant-only error response +$tenantOnly = function (ServerRequestInterface $request) use ($unauthorized): ?ResponseInterface { + $auth = $request->getAttribute('auth'); + if (!$auth || $auth['type'] !== 'user') { + return $unauthorized('Tenant user access required'); + } + return null; +}; + +// ========================================== +// PUBLIC ROUTES (no auth required) +// ========================================== + +// Health check +$app->get('/api/health', function (ServerRequestInterface $request, ResponseInterface $response) { + return \App\Support\Response::json($response, ['status' => 'ok', 'timestamp' => date('c')]); +}); + +// Auth routes +$app->post('/api/auth/login', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->login($request, $response); +}); + +$app->post('/api/auth/register', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->register($request, $response); +}); + +$app->post('/api/auth/verify-email', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->verifyEmail($request, $response); +}); + +$app->post('/api/auth/forgot-password', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->forgotPassword($request, $response); +}); + +// ========================================== +// AUTHENTICATED ROUTES +// ========================================== +$app->add(function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($authMiddleware) { + return $authMiddleware($request, $handler); +}); + +// Auth (requires auth) +$app->post('/api/auth/logout', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->logout($request, $response); +}); + +$app->post('/api/auth/refresh', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->refresh($request, $response); +}); + +$app->get('/api/auth/me', function (ServerRequestInterface $request, ResponseInterface $response) use ($authController) { + return $authController->me($request, $response); +}); + +// ========================================== +// PLATFORM ADMIN ROUTES +// ========================================== +$app->group('/api/admin', function (\Slim\Routing\RouteCollectorProxy $group) use ($adminController, $adminOnly) { + // Tenants + $group->get('/tenants', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->listTenants($request, $response); + }); + + $group->post('/tenants', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->createTenant($request, $response); + }); + + $group->get('/tenants/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->getTenant($request, $response, $args); + }); + + $group->put('/tenants/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->updateTenant($request, $response, $args); + }); + + $group->delete('/tenants/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->deleteTenant($request, $response, $args); + }); + + $group->post('/tenants/{id}/approve', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->approveTenant($request, $response, $args); + }); + + $group->post('/tenants/{id}/suspend', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->suspendTenant($request, $response, $args); + }); + + // Tenant Modules + $group->get('/tenants/{id}/modules', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->getTenantModules($request, $response, $args); + }); + + $group->post('/tenants/{id}/modules', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->enableTenantModule($request, $response, $args); + }); + + $group->delete('/tenants/{id}/modules/{moduleId}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->disableTenantModule($request, $response, $args); + }); + + // Platform Modules + $group->get('/platform-modules', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->listPlatformModules($request, $response); + }); + + // Audit Logs + $group->get('/audit-logs', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->listAuditLogs($request, $response); + }); + + // Invoices + $group->get('/invoices', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->listInvoices($request, $response); + }); + + $group->put('/invoices/{id}/mark-paid', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->markInvoicePaid($request, $response, $args); + }); + + // Platform Admins + $group->get('/platform-admins', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->listPlatformAdmins($request, $response); + }); + + $group->post('/platform-admins', function (ServerRequestInterface $request, ResponseInterface $response) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->createPlatformAdmin($request, $response); + }); + + $group->put('/platform-admins/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->updatePlatformAdmin($request, $response, $args); + }); + + $group->delete('/platform-admins/{id}', function (ServerRequestInterface $request, ResponseInterface $response, array $args) use ($adminController, $adminOnly) { + if ($err = $adminOnly($request)) return $err; + return $adminController->deletePlatformAdmin($request, $response, $args); + }); +}); + +// ========================================== +// TENANT ROUTES (authenticated tenant users) +// ========================================== +$app->group('/api', function (\Slim\Routing\RouteCollectorProxy $group) use ($tenantOnly) { + // These routes need tenant user auth - placeholder for now + // Will be implemented in tenant routes file +}); + +// Catch-all for API (404) +$app->any('/api/{path:.*}', function (ServerRequestInterface $request, ResponseInterface $response) { + return \App\Support\Response::notFound($response, 'API endpoint not found'); +}); \ No newline at end of file diff --git a/www/fahrschuldesk.de/index.html b/www/fahrschuldesk.de/index.html new file mode 100644 index 0000000..5636b75 --- /dev/null +++ b/www/fahrschuldesk.de/index.html @@ -0,0 +1,17 @@ + + + + + + + + fahrschuldesk + + + + + +
+ + + \ No newline at end of file diff --git a/www/fahrschuldesk.de/package.json b/www/fahrschuldesk.de/package.json new file mode 100644 index 0000000..7888263 --- /dev/null +++ b/www/fahrschuldesk.de/package.json @@ -0,0 +1,24 @@ +{ + "name": "fahrschuldesk-ui", + "description": "fahrschuldesk WebUI", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "pinia": "^2.1.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "vite": "^5.0.0" + } +} \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/App.vue b/www/fahrschuldesk.de/src/App.vue new file mode 100644 index 0000000..55199ae --- /dev/null +++ b/www/fahrschuldesk.de/src/App.vue @@ -0,0 +1,31 @@ + + + + + \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/api/client.js b/www/fahrschuldesk.de/src/api/client.js new file mode 100644 index 0000000..c9bacd8 --- /dev/null +++ b/www/fahrschuldesk.de/src/api/client.js @@ -0,0 +1,59 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json' + } +}) + +// Request interceptor - add auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +// Response interceptor - handle 401 and refresh token +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config + + // If 401 and not already retrying + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true + + const refreshToken = localStorage.getItem('refresh_token') + if (refreshToken) { + try { + const response = await axios.post('/api/auth/refresh', { + refresh_token: refreshToken + }) + + const { access_token, refresh_token } = response.data.data + localStorage.setItem('access_token', access_token) + localStorage.setItem('refresh_token', refresh_token) + + // Retry original request + originalRequest.headers.Authorization = `Bearer ${access_token}` + return api(originalRequest) + } catch (refreshError) { + // Refresh failed, logout + localStorage.clear() + window.location.href = '/login' + return Promise.reject(refreshError) + } + } + } + + return Promise.reject(error) + } +) + +export default api \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/main.js b/www/fahrschuldesk.de/src/main.js new file mode 100644 index 0000000..f8f0904 --- /dev/null +++ b/www/fahrschuldesk.de/src/main.js @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './styles/main.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/router/index.js b/www/fahrschuldesk.de/src/router/index.js new file mode 100644 index 0000000..98701d5 --- /dev/null +++ b/www/fahrschuldesk.de/src/router/index.js @@ -0,0 +1,147 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/', + redirect: '/dashboard' + }, + { + path: '/login', + name: 'Login', + component: () => import('@/views/auth/Login.vue'), + meta: { guest: true } + }, + { + path: '/register', + name: 'Register', + component: () => import('@/views/auth/Register.vue'), + meta: { guest: true } + }, + { + path: '/forgot-password', + name: 'ForgotPassword', + component: () => import('@/views/auth/ForgotPassword.vue'), + meta: { guest: true } + }, + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { requiresAuth: true } + }, + // Admin routes + { + path: '/admin/tenants', + name: 'Tenants', + component: () => import('@/views/admin/Tenants.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + { + path: '/admin/tenants/:id', + name: 'TenantEdit', + component: () => import('@/views/admin/TenantEdit.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + { + path: '/admin/modules', + name: 'Modules', + component: () => import('@/views/admin/Modules.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + { + path: '/admin/audit-logs', + name: 'AuditLogs', + component: () => import('@/views/admin/AuditLogs.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + { + path: '/admin/invoices', + name: 'Invoices', + component: () => import('@/views/admin/Invoices.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + { + path: '/admin/admins', + name: 'PlatformAdmins', + component: () => import('@/views/admin/PlatformAdmins.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + // Tenant routes + { + path: '/branches', + name: 'Branches', + component: () => import('@/views/tenant/Branches.vue'), + meta: { requiresAuth: true, module: 'office' } + }, + { + path: '/users', + name: 'Users', + component: () => import('@/views/tenant/Users.vue'), + meta: { requiresAuth: true, module: 'office' } + }, + { + path: '/calendar', + name: 'Calendar', + component: () => import('@/views/tenant/Calendar.vue'), + meta: { requiresAuth: true, module: 'calendar' } + }, + { + path: '/messenger', + name: 'Messenger', + component: () => import('@/views/tenant/Messenger.vue'), + meta: { requiresAuth: true, module: 'messenger' } + }, + { + path: '/learning', + name: 'Learning', + component: () => import('@/views/tenant/Learning.vue'), + meta: { requiresAuth: true, module: ['learning_blank', 'learning_premium'] } + }, + { + path: '/files', + name: 'Files', + component: () => import('@/views/tenant/Files.vue'), + meta: { requiresAuth: true, module: 'office' } + }, + { + path: '/branding', + name: 'Branding', + component: () => import('@/views/tenant/Branding.vue'), + meta: { requiresAuth: true, adminOnly: true } + }, + // Catch all + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFound.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +router.beforeEach((to, from, next) => { + const auth = useAuthStore() + + if (to.meta.requiresAuth && !auth.isAuthenticated) { + next({ name: 'Login', query: { redirect: to.fullPath } }) + return + } + + if (to.meta.adminOnly && !auth.isPlatformAdmin) { + next({ name: 'Dashboard' }) + return + } + + if (to.meta.guest && auth.isAuthenticated) { + next({ name: 'Dashboard' }) + return + } + + next() +}) + +export default router \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/stores/auth.js b/www/fahrschuldesk.de/src/stores/auth.js new file mode 100644 index 0000000..498bf60 --- /dev/null +++ b/www/fahrschuldesk.de/src/stores/auth.js @@ -0,0 +1,124 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import api from '@/api/client' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const tenant = ref(null) + const accessToken = ref(localStorage.getItem('access_token') || null) + const refreshToken = ref(localStorage.getItem('refresh_token') || null) + + const isAuthenticated = computed(() => !!accessToken.value) + + const isPlatformAdmin = computed(() => user.value?.type === 'platform_admin') + + const isTenantAdmin = computed(() => user.value?.user_type === 'admin') + + async function login(email, password) { + try { + const response = await api.post('/auth/login', { email, password }) + const { data } = response.data + + accessToken.value = data.access_token + refreshToken.value = data.refresh_token + + localStorage.setItem('access_token', data.access_token) + localStorage.setItem('refresh_token', data.refresh_token) + + await fetchUser() + + return { success: true } + } catch (error) { + return { + success: false, + message: error.response?.data?.message || 'Login failed' + } + } + } + + async function register(payload) { + try { + const response = await api.post('/auth/register', payload) + return { success: true, data: response.data.data } + } catch (error) { + return { + success: false, + message: error.response?.data?.message || 'Registration failed', + errors: error.response?.data?.errors || {} + } + } + } + + async function fetchUser() { + try { + const response = await api.get('/auth/me') + user.value = response.data.data + + if (user.value.tenant_id) { + tenant.value = { + id: user.value.tenant_id, + name: user.value.tenant_name, + slug: user.value.tenant_slug, + status: user.value.tenant_status, + primary_color: user.value.primary_color, + secondary_color: user.value.secondary_color, + } + } + } catch (error) { + console.error('Failed to fetch user:', error) + logout() + } + } + + async function refreshAccessToken() { + if (!refreshToken.value) return false + + try { + const response = await api.post('/auth/refresh', { + refresh_token: refreshToken.value + }) + + const { data } = response.data + accessToken.value = data.access_token + refreshToken.value = data.refresh_token + + localStorage.setItem('access_token', data.access_token) + localStorage.setItem('refresh_token', data.refresh_token) + + return true + } catch (error) { + logout() + return false + } + } + + function logout() { + user.value = null + tenant.value = null + accessToken.value = null + refreshToken.value = null + + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + } + + // Initialize + if (accessToken.value) { + fetchUser() + } + + return { + user, + tenant, + accessToken, + refreshToken, + isAuthenticated, + isPlatformAdmin, + isTenantAdmin, + login, + register, + fetchUser, + refreshAccessToken, + logout + } +}) \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/styles/main.css b/www/fahrschuldesk.de/src/styles/main.css new file mode 100644 index 0000000..723f126 --- /dev/null +++ b/www/fahrschuldesk.de/src/styles/main.css @@ -0,0 +1,71 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --color-primary: #2563eb; + --color-primary-50: #eff6ff; + --color-primary-100: #dbeafe; + --color-primary-500: #3b82f6; + --color-primary-600: #2563eb; + --color-primary-700: #1d4ed8; + --color-secondary: #64748b; + --color-secondary-500: #64748b; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Transitions */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-enter-active, +.slide-leave-active { + transition: transform 0.3s ease, opacity 0.3s ease; +} + +.slide-enter-from { + transform: translateX(-20px); + opacity: 0; +} + +.slide-leave-to { + transform: translateX(20px); + opacity: 0; +} \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/views/Dashboard.vue b/www/fahrschuldesk.de/src/views/Dashboard.vue new file mode 100644 index 0000000..f78ef46 --- /dev/null +++ b/www/fahrschuldesk.de/src/views/Dashboard.vue @@ -0,0 +1,135 @@ + + + \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/views/NotFound.vue b/www/fahrschuldesk.de/src/views/NotFound.vue new file mode 100644 index 0000000..ea49f0c --- /dev/null +++ b/www/fahrschuldesk.de/src/views/NotFound.vue @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/views/admin/Tenants.vue b/www/fahrschuldesk.de/src/views/admin/Tenants.vue new file mode 100644 index 0000000..cada126 --- /dev/null +++ b/www/fahrschuldesk.de/src/views/admin/Tenants.vue @@ -0,0 +1,280 @@ + + + \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/views/auth/ForgotPassword.vue b/www/fahrschuldesk.de/src/views/auth/ForgotPassword.vue new file mode 100644 index 0000000..c6b04c0 --- /dev/null +++ b/www/fahrschuldesk.de/src/views/auth/ForgotPassword.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/views/auth/Login.vue b/www/fahrschuldesk.de/src/views/auth/Login.vue new file mode 100644 index 0000000..debdac2 --- /dev/null +++ b/www/fahrschuldesk.de/src/views/auth/Login.vue @@ -0,0 +1,142 @@ + + + \ No newline at end of file diff --git a/www/fahrschuldesk.de/src/views/auth/Register.vue b/www/fahrschuldesk.de/src/views/auth/Register.vue new file mode 100644 index 0000000..9512777 --- /dev/null +++ b/www/fahrschuldesk.de/src/views/auth/Register.vue @@ -0,0 +1,206 @@ +