Phase 1: API + Frontend Skelett
This commit is contained in:
12
config/database.example
Normal file
12
config/database.example
Normal file
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
317
www/api.fahrschuldesk.de/app/Http/Controllers/AuthController.php
Normal file
317
www/api.fahrschuldesk.de/app/Http/Controllers/AuthController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
113
www/api.fahrschuldesk.de/app/Http/Middleware/AuthMiddleware.php
Normal file
113
www/api.fahrschuldesk.de/app/Http/Middleware/AuthMiddleware.php
Normal 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
|
||||
}
|
||||
}
|
||||
104
www/api.fahrschuldesk.de/app/Support/Database.php
Normal file
104
www/api.fahrschuldesk.de/app/Support/Database.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Database Support Class
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $connection = null;
|
||||
|
||||
public static function setConnection(PDO $pdo): void
|
||||
{
|
||||
self::$connection = $pdo;
|
||||
}
|
||||
|
||||
public static function getConnection(): PDO
|
||||
{
|
||||
if (self::$connection === null) {
|
||||
throw new \RuntimeException('Database connection not initialized');
|
||||
}
|
||||
return self::$connection;
|
||||
}
|
||||
|
||||
public static function fetch(string $sql, array $params = []): ?array
|
||||
{
|
||||
$stmt = self::getConnection()->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();
|
||||
}
|
||||
}
|
||||
77
www/api.fahrschuldesk.de/app/Support/Response.php
Normal file
77
www/api.fahrschuldesk.de/app/Support/Response.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* JSON Response Helper
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class Response
|
||||
{
|
||||
public static function json(
|
||||
ResponseInterface $response,
|
||||
mixed $data,
|
||||
int $status = 200
|
||||
): ResponseInterface {
|
||||
$response->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);
|
||||
}
|
||||
}
|
||||
87
www/api.fahrschuldesk.de/bin/migrate.php
Normal file
87
www/api.fahrschuldesk.de/bin/migrate.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration Runner
|
||||
*
|
||||
* Run: php bin/migrate.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Load config
|
||||
$config = require __DIR__ . '/../config/app.php';
|
||||
|
||||
echo "fahrschuldesk Migration Runner\n";
|
||||
echo "==============================\n\n";
|
||||
|
||||
// Database connection
|
||||
$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,
|
||||
]);
|
||||
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";
|
||||
52
www/api.fahrschuldesk.de/bootstrap.php
Normal file
52
www/api.fahrschuldesk.de/bootstrap.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/**
|
||||
* fahrschuldesk API Bootstrap
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Error reporting
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// Base paths
|
||||
define('ROOT_PATH', dirname(__DIR__));
|
||||
define('STORAGE_PATH', ROOT_PATH . '/storage');
|
||||
define('CONFIG_PATH', ROOT_PATH . '/config');
|
||||
|
||||
// Load Composer autoloader
|
||||
require ROOT_PATH . '/vendor/autoload.php';
|
||||
|
||||
// Load configuration
|
||||
$config = require CONFIG_PATH . '/app.php';
|
||||
|
||||
// Create DI container
|
||||
$container = new \DI\Container();
|
||||
|
||||
// Database connection
|
||||
$container->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;
|
||||
22
www/api.fahrschuldesk.de/composer.json
Normal file
22
www/api.fahrschuldesk.de/composer.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
48
www/api.fahrschuldesk.de/config/app.php
Normal file
48
www/api.fahrschuldesk.de/config/app.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* fahrschuldesk API Configuration
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'app' => [
|
||||
'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,
|
||||
],
|
||||
];
|
||||
@@ -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();
|
||||
@@ -1,2 +1,75 @@
|
||||
<?php
|
||||
echo 'api.fahrschuldesk.de - ' . date('Y-m-d H:i:s');
|
||||
/**
|
||||
* fahrschuldesk API - Entry Point
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
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;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
// Load config
|
||||
$config = require __DIR__ . '/../config/app.php';
|
||||
|
||||
// Set timezone
|
||||
date_default_timezone_set($config['app']['timezone']);
|
||||
|
||||
// Create app
|
||||
$app = AppFactory::create();
|
||||
|
||||
// Get container
|
||||
$container = $app->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();
|
||||
208
www/api.fahrschuldesk.de/routes/api.php
Normal file
208
www/api.fahrschuldesk.de/routes/api.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
/**
|
||||
* fahrschuldesk API Routes
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\AdminController;
|
||||
use App\Http\Middleware\AuthMiddleware;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
// Load config
|
||||
$config = require __DIR__ . '/../config/app.php';
|
||||
|
||||
// Create controllers
|
||||
$authController = new AuthController($config);
|
||||
$adminController = new AdminController($config);
|
||||
|
||||
// Create middleware
|
||||
$authMiddleware = new AuthMiddleware($config['jwt']['secret']);
|
||||
|
||||
// Helper to create error response
|
||||
$unauthorized = function (string $message = 'Unauthorized'): ResponseInterface {
|
||||
$response = new \Slim\Psr7\Response();
|
||||
return (new \App\Support\Response())->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');
|
||||
});
|
||||
17
www/fahrschuldesk.de/index.html
Normal file
17
www/fahrschuldesk.de/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="fahrschuldesk - Verwaltungssoftware für Fahrschulen" />
|
||||
<title>fahrschuldesk</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
www/fahrschuldesk.de/package.json
Normal file
24
www/fahrschuldesk.de/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
31
www/fahrschuldesk.de/src/App.vue
Normal file
31
www/fahrschuldesk.de/src/App.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div id="app" :style="customStyles">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const customStyles = computed(() => {
|
||||
if (!auth.tenant) return {}
|
||||
return {
|
||||
'--color-primary': auth.tenant.primary_color || '#2563eb',
|
||||
'--color-secondary': auth.tenant.secondary_color || '#64748b',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
59
www/fahrschuldesk.de/src/api/client.js
Normal file
59
www/fahrschuldesk.de/src/api/client.js
Normal file
@@ -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
|
||||
12
www/fahrschuldesk.de/src/main.js
Normal file
12
www/fahrschuldesk.de/src/main.js
Normal file
@@ -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')
|
||||
147
www/fahrschuldesk.de/src/router/index.js
Normal file
147
www/fahrschuldesk.de/src/router/index.js
Normal file
@@ -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
|
||||
124
www/fahrschuldesk.de/src/stores/auth.js
Normal file
124
www/fahrschuldesk.de/src/stores/auth.js
Normal file
@@ -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
|
||||
}
|
||||
})
|
||||
71
www/fahrschuldesk.de/src/styles/main.css
Normal file
71
www/fahrschuldesk.de/src/styles/main.css
Normal file
@@ -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;
|
||||
}
|
||||
135
www/fahrschuldesk.de/src/views/Dashboard.vue
Normal file
135
www/fahrschuldesk.de/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-white border-b border-slate-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-lg font-bold text-slate-800">fahrschuldesk</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="$route.path === '/dashboard' ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
|
||||
>
|
||||
Dashboard
|
||||
</router-link>
|
||||
|
||||
<template v-if="auth.isPlatformAdmin">
|
||||
<router-link
|
||||
to="/admin/tenants"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="$route.path.startsWith('/admin/tenants') ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
|
||||
>
|
||||
Fahrschulen
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/invoices"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="$route.path.startsWith('/admin/invoices') ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
|
||||
>
|
||||
Rechnungen
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/audit-logs"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="$route.path.startsWith('/admin/audit') ? 'bg-primary text-white' : 'text-slate-600 hover:bg-slate-100'"
|
||||
>
|
||||
Audit-Log
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<router-link
|
||||
v-if="hasModule('office')"
|
||||
to="/users"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Benutzer
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="hasModule('calendar')"
|
||||
to="/calendar"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Kalender
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="hasModule('messenger')"
|
||||
to="/messenger"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Messenger
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="hasModule('learning_blank') || hasModule('learning_premium')"
|
||||
to="/learning"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Lernsoftware
|
||||
</router-link>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-right hidden sm:block">
|
||||
<p class="text-sm font-medium text-slate-800">{{ auth.user?.name || auth.user?.first_name }}</p>
|
||||
<p class="text-xs text-slate-500">{{ tenantLabel }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
title="Abmelden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tenantLabel = computed(() => {
|
||||
if (auth.isPlatformAdmin) return 'Plattform-Admin'
|
||||
return auth.tenant?.name || ''
|
||||
})
|
||||
|
||||
function hasModule(moduleKey) {
|
||||
if (auth.isPlatformAdmin) return true
|
||||
// TODO: check tenant modules
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
18
www/fahrschuldesk.de/src/views/NotFound.vue
Normal file
18
www/fahrschuldesk.de/src/views/NotFound.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="text-center">
|
||||
<div class="text-8xl font-bold text-slate-200 mb-4">404</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 mb-2">Seite nicht gefunden</h1>
|
||||
<p class="text-slate-500 mb-8">Die Seite, die du suchst, existiert nicht.</p>
|
||||
<router-link
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Zum Dashboard
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
280
www/fahrschuldesk.de/src/views/admin/Tenants.vue
Normal file
280
www/fahrschuldesk.de/src/views/admin/Tenants.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Fahrschulen</h1>
|
||||
<p class="text-slate-500 text-sm mt-1">Verwalten Sie alle registrierten Fahrschulen</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Fahrschule anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
class="w-full pl-10 pr-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="px-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="pending_approval">Ausstehend</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="trial">Testphase</option>
|
||||
<option value="suspended">Suspendiert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Fahrschule</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Module</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Registriert</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-slate-600 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
<tr v-for="tenant in filteredTenants" :key="tenant.id" class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="font-medium text-slate-800">{{ tenant.name }}</p>
|
||||
<p class="text-sm text-slate-500">{{ tenant.email }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="statusClass(tenant.status)"
|
||||
>
|
||||
{{ statusLabel(tenant.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-for="mod in tenant.modules?.slice(0, 3)"
|
||||
:key="mod"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600"
|
||||
>
|
||||
{{ mod }}
|
||||
</span>
|
||||
<span v-if="tenant.module_count > 3" class="text-xs text-slate-400">
|
||||
+{{ tenant.module_count - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-500">
|
||||
{{ formatDate(tenant.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<router-link
|
||||
:to="`/admin/tenants/${tenant.id}`"
|
||||
class="p-2 text-slate-400 hover:text-primary rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="tenant.status === 'pending_approval'"
|
||||
@click="approveTenant(tenant)"
|
||||
class="p-2 text-slate-400 hover:text-green-600 rounded-lg hover:bg-green-50 transition-colors"
|
||||
title="Genehmigen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="tenant.status !== 'suspended'"
|
||||
@click="suspendTenant(tenant)"
|
||||
class="p-2 text-slate-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="Suspendieren"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredTenants.length === 0">
|
||||
<td colspan="5" class="px-6 py-12 text-center text-slate-500">
|
||||
Keine Fahrschulen gefunden
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="px-6 py-4 border-t border-slate-200 flex items-center justify-between">
|
||||
<p class="text-sm text-slate-500">
|
||||
Zeige {{ pagination.start }} - {{ pagination.end }} von {{ pagination.total }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="page--"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 rounded border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
@click="page++"
|
||||
:disabled="page >= pagination.pages"
|
||||
class="px-3 py-1 rounded border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-slate-800 mb-4">Fahrschule anlegen</h3>
|
||||
<form @submit.prevent="createTenant" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input v-model="newTenant.name" type="text" required class="w-full px-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">E-Mail *</label>
|
||||
<input v-model="newTenant.email" type="email" required class="w-full px-4 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary outline-none" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<button type="button" @click="showCreateModal = false" class="px-4 py-2 rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" :disabled="creating" class="px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary-600 disabled:opacity-50">
|
||||
{{ creating ? 'Erstelle...' : 'Erstellen' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import api from '@/api/client'
|
||||
|
||||
const tenants = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const newTenant = reactive({ name: '', email: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
const filteredTenants = computed(() => {
|
||||
return tenants.value
|
||||
})
|
||||
|
||||
const pagination = computed(() => ({
|
||||
start: (page.value - 1) * limit.value + 1,
|
||||
end: Math.min(page.value * limit.value, tenants.value.length),
|
||||
total: tenants.value.length,
|
||||
pages: Math.ceil(tenants.value.length / limit.value)
|
||||
}))
|
||||
|
||||
async function fetchTenants() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/admin/tenants', { params: { page: page.value, limit: limit.value, status: statusFilter.value || undefined } })
|
||||
tenants.value = response.data.data.tenants
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tenants:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTenant() {
|
||||
creating.value = true
|
||||
try {
|
||||
await api.post('/admin/tenants', newTenant)
|
||||
showCreateModal.value = false
|
||||
Object.assign(newTenant, { name: '', email: '' })
|
||||
await fetchTenants()
|
||||
} catch (error) {
|
||||
console.error('Failed to create tenant:', error)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approveTenant(tenant) {
|
||||
try {
|
||||
await api.post(`/admin/tenants/${tenant.id}/approve`)
|
||||
await fetchTenants()
|
||||
} catch (error) {
|
||||
console.error('Failed to approve tenant:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function suspendTenant(tenant) {
|
||||
try {
|
||||
await api.post(`/admin/tenants/${tenant.id}/suspend`)
|
||||
await fetchTenants()
|
||||
} catch (error) {
|
||||
console.error('Failed to suspend tenant:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const classes = {
|
||||
pending_approval: 'bg-yellow-100 text-yellow-800',
|
||||
active: 'bg-green-100 text-green-800',
|
||||
trial: 'bg-blue-100 text-blue-800',
|
||||
suspended: 'bg-red-100 text-red-800'
|
||||
}
|
||||
return classes[status] || 'bg-slate-100 text-slate-800'
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const labels = {
|
||||
pending_approval: 'Ausstehend',
|
||||
active: 'Aktiv',
|
||||
trial: 'Testphase',
|
||||
suspended: 'Suspendiert'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(fetchTenants)
|
||||
</script>
|
||||
71
www/fahrschuldesk.de/src/views/auth/ForgotPassword.vue
Normal file
71
www/fahrschuldesk.de/src/views/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-primary mb-4">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-slate-800">Passwort vergessen?</h2>
|
||||
<p class="text-slate-500 text-sm mt-1">Kein Problem. Geben Sie Ihre E-Mail ein.</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">E-Mail</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="ihre@email.de"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="message" class="p-3 rounded-lg text-sm" :class="success ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-600'">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Senden...' : 'Link senden' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-500 mt-6">
|
||||
Zurück zum <router-link to="/login" class="text-primary hover:underline">Login</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api/client'
|
||||
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
const success = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
try {
|
||||
await api.post('/auth/forgot-password', { email: email.value })
|
||||
success.value = true
|
||||
message.value = 'Wenn die E-Mail existiert, erhalten Sie einen Link zum Zurücksetzen.'
|
||||
} catch {
|
||||
success.value = true
|
||||
message.value = 'Wenn die E-Mail existiert, erhalten Sie einen Link zum Zurücksetzen.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
142
www/fahrschuldesk.de/src/views/auth/Login.vue
Normal file
142
www/fahrschuldesk.de/src/views/auth/Login.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo & Title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary mb-4">
|
||||
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">fahrschuldesk</h1>
|
||||
<p class="text-slate-500 mt-1">Verwaltung für Fahrschulen</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 class="text-xl font-semibold text-slate-800 mb-6">Anmelden</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="ihre@email.de"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
|
||||
:class="{ 'border-red-400 focus:ring-red-200': errors.email }"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<p v-if="errors.email" class="mt-1 text-sm text-red-500">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="form.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="••••••••"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all pr-12"
|
||||
:class="{ 'border-red-400 focus:ring-red-200': errors.password }"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="mt-1 text-sm text-red-500">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="errorMessage" class="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 focus:ring-4 focus:ring-primary-200 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Anmelden...
|
||||
</span>
|
||||
<span v-else>Anmelden</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="mt-6 flex items-center justify-between text-sm">
|
||||
<router-link to="/forgot-password" class="text-primary hover:underline">
|
||||
Passwort vergessen?
|
||||
</router-link>
|
||||
<router-link to="/register" class="text-slate-500 hover:text-slate-700">
|
||||
Registrieren →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Demo hint -->
|
||||
<p class="text-center text-slate-400 text-sm mt-6">
|
||||
Noch kein Konto?
|
||||
<router-link to="/register" class="text-primary hover:underline">Jetzt Fahrschule registrieren</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const errors = reactive({})
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
errorMessage.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
|
||||
loading.value = true
|
||||
|
||||
const result = await auth.login(form.email, form.password)
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
const redirect = route.query.redirect || '/dashboard'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
errorMessage.value = result.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
206
www/fahrschuldesk.de/src/views/auth/Register.vue
Normal file
206
www/fahrschuldesk.de/src/views/auth/Register.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-100 to-slate-200 px-4 py-12">
|
||||
<div class="w-full max-w-lg">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary mb-4">
|
||||
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Fahrschule registrieren</h1>
|
||||
<p class="text-slate-500 mt-1">Starten Sie Ihre 14-tägige kostenlose Testphase</p>
|
||||
</div>
|
||||
|
||||
<!-- Form Card -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<form @submit.prevent="handleRegister" class="space-y-5">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Name der Fahrschule *</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Muster Fahrschule"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
|
||||
:class="{ 'border-red-400': errors.name }"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-red-500">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">E-Mail *</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="info@fahrschule.de"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
|
||||
:class="{ 'border-red-400': errors.email }"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<p v-if="errors.email" class="mt-1 text-sm text-red-500">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Telefon</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
placeholder="+49 123 456789"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Adresse</label>
|
||||
<textarea
|
||||
v-model="form.address"
|
||||
rows="2"
|
||||
placeholder="Straße, PLZ Ort"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all resize-none"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Passwort *</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
|
||||
:class="{ 'border-red-400': errors.password }"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<p v-if="errors.password" class="mt-1 text-sm text-red-500">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Confirm -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen *</label>
|
||||
<input
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
placeholder="Passwort wiederholen"
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-slate-300 focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-all"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Terms -->
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
v-model="form.accept_terms"
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
class="mt-1 rounded border-slate-300 text-primary focus:ring-primary"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<label for="terms" class="text-sm text-slate-600">
|
||||
Ich akzeptiere die
|
||||
<a href="#" class="text-primary hover:underline">AGB</a> und
|
||||
<a href="#" class="text-primary hover:underline">Datenschutzerklärung</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="errorMessage" class="p-3 rounded-lg bg-red-50 text-red-600 text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div v-if="successMessage" class="p-3 rounded-lg bg-green-50 text-green-600 text-sm">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !form.accept_terms"
|
||||
class="w-full py-3 px-4 bg-primary text-white font-medium rounded-lg hover:bg-primary-600 focus:ring-4 focus:ring-primary-200 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Registrieren...
|
||||
</span>
|
||||
<span v-else>Jetzt kostenlos testen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-500 mt-6">
|
||||
Bereits registriert?
|
||||
<router-link to="/login" class="text-primary hover:underline">Jetzt anmelden</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
accept_terms: false
|
||||
})
|
||||
|
||||
const errors = reactive({})
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleRegister() {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
|
||||
if (form.password !== form.password_confirmation) {
|
||||
errors.password_confirmation = ['Passwörter stimmen nicht überein']
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
const result = await auth.register({
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
address: form.address,
|
||||
password: form.password
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
successMessage.value = 'Registrierung erfolgreich! Nach der Freigabe durch einen Admin erhalten Sie eine E-Mail.'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 3000)
|
||||
} else {
|
||||
errorMessage.value = result.message
|
||||
if (result.errors) {
|
||||
Object.assign(errors, result.errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
29
www/fahrschuldesk.de/tailwind.config.js
Normal file
29
www/fahrschuldesk.de/tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: 'var(--color-primary)',
|
||||
50: 'var(--color-primary-50)',
|
||||
100: 'var(--color-primary-100)',
|
||||
500: 'var(--color-primary-500)',
|
||||
600: 'var(--color-primary-600)',
|
||||
700: 'var(--color-primary-700)',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'var(--color-secondary)',
|
||||
500: 'var(--color-secondary-500)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
www/fahrschuldesk.de/vite.config.js
Normal file
21
www/fahrschuldesk.de/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://api.fahrschuldesk.de',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user