From eaa05d68c1f6e37e1ac93125380ecd98421c5e62 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 7 May 2026 19:25:27 +0000 Subject: [PATCH] Deploy 2: 2FA (App + Email) + REST API + CSV import-export + auto backup - Filament v5 multiFactorAuthentication enabled on both panels (App + Email) - HasAppAuthentication + HasEmailAuthentication on User and SuperAdmin - Migration: app_authentication_secret + recovery_codes + email_authentication_at - Sanctum REST API: /api/v1/login, /me, clients, vehicles, work-orders - EnsureTokenMatchesTenant middleware blocks cross-tenant token usage - CsvImportExport service: clients + vehicles bulk via plain CSV - Import/Export buttons on Client + Vehicle list pages - ApiTokens page in tenant panel (generate/revoke + last-used) - BackupAllTenantsCommand + scheduler (daily 03:00, retain 14 days) - Background scheduler in entrypoint.sh --- .../Commands/BackupAllTenantsCommand.php | 88 +++++++++ app/Filament/Tenant/Pages/ApiTokens.php | 78 ++++++++ .../ClientResource/Pages/ListClients.php | 36 +++- .../VehicleResource/Pages/ListVehicles.php | 36 +++- .../Controllers/Api/ApiAuthController.php | 83 ++++++++ .../Controllers/Api/ClientApiController.php | 67 +++++++ .../Controllers/Api/VehicleApiController.php | 66 +++++++ .../Api/WorkOrderApiController.php | 73 +++++++ .../Middleware/EnsureTokenMatchesTenant.php | 34 ++++ app/Models/Central/SuperAdmin.php | 50 ++++- app/Models/Tenant/User.php | 50 ++++- .../Filament/CentralPanelProvider.php | 5 + .../Filament/TenantPanelProvider.php | 5 + app/Services/CsvImportExport.php | 184 ++++++++++++++++++ bootstrap/app.php | 8 + config/sanctum.php | 22 +++ ...10_create_personal_access_tokens_table.php | 26 +++ ..._05_08_000020_add_mfa_columns_to_users.php | 32 +++ docker/entrypoint.sh | 12 ++ .../tenant/pages/api-tokens.blade.php | 87 +++++++++ routes/api.php | 20 ++ routes/console.php | 12 ++ 22 files changed, 1068 insertions(+), 6 deletions(-) create mode 100644 app/Console/Commands/BackupAllTenantsCommand.php create mode 100644 app/Filament/Tenant/Pages/ApiTokens.php create mode 100644 app/Http/Controllers/Api/ApiAuthController.php create mode 100644 app/Http/Controllers/Api/ClientApiController.php create mode 100644 app/Http/Controllers/Api/VehicleApiController.php create mode 100644 app/Http/Controllers/Api/WorkOrderApiController.php create mode 100644 app/Http/Middleware/EnsureTokenMatchesTenant.php create mode 100644 app/Services/CsvImportExport.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_05_08_000010_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_05_08_000020_add_mfa_columns_to_users.php create mode 100644 resources/views/filament/tenant/pages/api-tokens.blade.php create mode 100644 routes/api.php diff --git a/app/Console/Commands/BackupAllTenantsCommand.php b/app/Console/Commands/BackupAllTenantsCommand.php new file mode 100644 index 0000000..8503fc4 --- /dev/null +++ b/app/Console/Commands/BackupAllTenantsCommand.php @@ -0,0 +1,88 @@ +where('status', '!=', 'archived'); + if ($slug = $this->option('slug')) { + $query->where('slug', $slug); + } + $companies = $query->get(); + + if ($companies->isEmpty()) { + $this->warn('No tenants to back up.'); + return self::SUCCESS; + } + + $date = date('Y-m-d'); + $base = storage_path("app/backups/{$date}"); + if (! is_dir($base)) @mkdir($base, 0775, true); + + $manager = app(TenantManager::class); + $ok = 0; + $failed = 0; + + foreach ($companies as $company) { + try { + $manager->setCurrent($company); + app(\Spatie\Permission\PermissionRegistrar::class) + ->setPermissionsTeamId($company->id); + + $tmp = $service->export($company); + $dest = "{$base}/{$company->slug}.zip"; + @rename($tmp, $dest); + + $size = round(filesize($dest) / 1024, 1); + $this->info("✓ {$company->slug} → {$size}KB"); + $ok++; + } catch (\Throwable $e) { + $this->error("✗ {$company->slug}: {$e->getMessage()}"); + $failed++; + } finally { + $manager->setCurrent(null); + } + } + + $this->cleanupOld((int) $this->option('keep')); + + $this->newLine(); + $this->info("Backup completed: {$ok} ok, {$failed} failed."); + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + private function cleanupOld(int $keep): void + { + $backupsDir = storage_path('app/backups'); + if (! is_dir($backupsDir)) return; + + $dates = collect(scandir($backupsDir)) + ->filter(fn ($d) => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) + ->sortDesc() + ->values(); + + $toDelete = $dates->slice($keep); + foreach ($toDelete as $dateDir) { + $abs = "{$backupsDir}/{$dateDir}"; + if (is_dir($abs)) { + array_map('unlink', glob("{$abs}/*")); + @rmdir($abs); + $this->line("Removed old backup: {$dateDir}"); + } + } + } +} diff --git a/app/Filament/Tenant/Pages/ApiTokens.php b/app/Filament/Tenant/Pages/ApiTokens.php new file mode 100644 index 0000000..300cc3a --- /dev/null +++ b/app/Filament/Tenant/Pages/ApiTokens.php @@ -0,0 +1,78 @@ +user(); + if (! $u) return []; + return $u->tokens()->latest()->get()->map(fn ($t) => [ + 'id' => $t->id, + 'name' => $t->name, + 'last_used_at' => $t->last_used_at?->diffForHumans() ?? '—', + 'created_at' => $t->created_at->diffForHumans(), + 'abilities' => is_array($t->abilities) ? implode(', ', $t->abilities) : '*', + ])->all(); + } + + protected function getHeaderActions(): array + { + return [ + Actions\Action::make('create') + ->label('Generează token') + ->icon('heroicon-m-plus') + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Nume (descriere)') + ->required() + ->placeholder('ex: app-mobil, integrare-erp'), + ]) + ->action(function (array $data) { + $u = auth()->user(); + if (! $u) return; + $token = $u->createToken($data['name']); + $this->newToken = $token->plainTextToken; + Notification::make() + ->title('Token generat!') + ->body('Copiază token-ul ACUM — nu va mai fi afișat.') + ->success() + ->persistent() + ->send(); + }), + ]; + } + + public function deleteToken(int $id): void + { + $u = auth()->user(); + if (! $u) return; + $u->tokens()->where('id', $id)->delete(); + Notification::make()->title('Token revocat')->success()->send(); + } + + public function dismissNew(): void + { + $this->newToken = null; + } +} diff --git a/app/Filament/Tenant/Resources/ClientResource/Pages/ListClients.php b/app/Filament/Tenant/Resources/ClientResource/Pages/ListClients.php index c3e650d..8d0be46 100644 --- a/app/Filament/Tenant/Resources/ClientResource/Pages/ListClients.php +++ b/app/Filament/Tenant/Resources/ClientResource/Pages/ListClients.php @@ -3,7 +3,10 @@ namespace App\Filament\Tenant\Resources\ClientResource\Pages; use App\Filament\Tenant\Resources\ClientResource; +use App\Services\CsvImportExport; use Filament\Actions; +use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListClients extends ListRecords @@ -12,6 +15,37 @@ class ListClients extends ListRecords protected function getHeaderActions(): array { - return [Actions\CreateAction::make()]; + return [ + Actions\Action::make('export') + ->label('Export CSV') + ->icon('heroicon-m-arrow-down-tray') + ->color('gray') + ->action(fn () => app(CsvImportExport::class)->exportClients()), + Actions\Action::make('import') + ->label('Import CSV') + ->icon('heroicon-m-arrow-up-tray') + ->color('gray') + ->modalHeading('Import clienți din CSV') + ->modalDescription('CSV cu header: ' . implode(', ', CsvImportExport::CLIENT_COLUMNS) . '. Deduplicare după telefon.') + ->schema([ + Forms\Components\FileUpload::make('file') + ->required() + ->disk('local') + ->directory('imports') + ->acceptedFileTypes(['text/csv', 'text/plain', 'application/csv']) + ->maxSize(5120), + ]) + ->action(function (array $data) { + $abs = \Illuminate\Support\Facades\Storage::disk('local')->path($data['file']); + $r = app(CsvImportExport::class)->importClients($abs); + @unlink($abs); + Notification::make() + ->title("Import: {$r['imported']} adăugați, {$r['skipped']} ignorați") + ->body(empty($r['errors']) ? null : implode("\n", array_slice($r['errors'], 0, 8))) + ->{empty($r['errors']) ? 'success' : 'warning'}() + ->send(); + }), + Actions\CreateAction::make(), + ]; } } diff --git a/app/Filament/Tenant/Resources/VehicleResource/Pages/ListVehicles.php b/app/Filament/Tenant/Resources/VehicleResource/Pages/ListVehicles.php index cc287f6..539220b 100644 --- a/app/Filament/Tenant/Resources/VehicleResource/Pages/ListVehicles.php +++ b/app/Filament/Tenant/Resources/VehicleResource/Pages/ListVehicles.php @@ -3,7 +3,10 @@ namespace App\Filament\Tenant\Resources\VehicleResource\Pages; use App\Filament\Tenant\Resources\VehicleResource; +use App\Services\CsvImportExport; use Filament\Actions; +use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; class ListVehicles extends ListRecords @@ -12,6 +15,37 @@ class ListVehicles extends ListRecords protected function getHeaderActions(): array { - return [Actions\CreateAction::make()]; + return [ + Actions\Action::make('export') + ->label('Export CSV') + ->icon('heroicon-m-arrow-down-tray') + ->color('gray') + ->action(fn () => app(CsvImportExport::class)->exportVehicles()), + Actions\Action::make('import') + ->label('Import CSV') + ->icon('heroicon-m-arrow-up-tray') + ->color('gray') + ->modalHeading('Import mașini din CSV') + ->modalDescription('CSV cu header: ' . implode(', ', CsvImportExport::VEHICLE_COLUMNS) . '. Coloana client_phone trebuie să existe deja la clienți.') + ->schema([ + Forms\Components\FileUpload::make('file') + ->required() + ->disk('local') + ->directory('imports') + ->acceptedFileTypes(['text/csv', 'text/plain', 'application/csv']) + ->maxSize(5120), + ]) + ->action(function (array $data) { + $abs = \Illuminate\Support\Facades\Storage::disk('local')->path($data['file']); + $r = app(CsvImportExport::class)->importVehicles($abs); + @unlink($abs); + Notification::make() + ->title("Import: {$r['imported']} adăugate, {$r['skipped']} ignorate") + ->body(empty($r['errors']) ? null : implode("\n", array_slice($r['errors'], 0, 8))) + ->{empty($r['errors']) ? 'success' : 'warning'}() + ->send(); + }), + Actions\CreateAction::make(), + ]; } } diff --git a/app/Http/Controllers/Api/ApiAuthController.php b/app/Http/Controllers/Api/ApiAuthController.php new file mode 100644 index 0000000..921a971 --- /dev/null +++ b/app/Http/Controllers/Api/ApiAuthController.php @@ -0,0 +1,83 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required', + 'device' => 'sometimes|string|max:80', + ]); + + $tenant = app(TenantManager::class)->current(); + if (! $tenant) { + throw ValidationException::withMessages([ + 'email' => 'Tenant subdomain required.', + ]); + } + + $user = User::where('email', $request->email)->first(); + + if (! $user || ! Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => 'Invalid credentials.', + ]); + } + + if ($user->company_id !== $tenant->id) { + throw ValidationException::withMessages([ + 'email' => 'User does not belong to this tenant.', + ]); + } + + if ($user->status !== 'active') { + throw ValidationException::withMessages([ + 'email' => 'Account inactive.', + ]); + } + + $token = $user->createToken($request->input('device', 'api'))->plainTextToken; + + return response()->json([ + 'token' => $token, + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role, + ], + 'tenant' => [ + 'slug' => $tenant->slug, + 'name' => $tenant->display_name ?? $tenant->name, + ], + ]); + } + + public function me(Request $request): JsonResponse + { + $u = $request->user(); + return response()->json([ + 'id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + 'role' => $u->role, + 'tenant_slug' => app(TenantManager::class)->current()?->slug, + ]); + } + + public function logout(Request $request): JsonResponse + { + $request->user()->currentAccessToken()->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/ClientApiController.php b/app/Http/Controllers/Api/ClientApiController.php new file mode 100644 index 0000000..9b678bf --- /dev/null +++ b/app/Http/Controllers/Api/ClientApiController.php @@ -0,0 +1,67 @@ +query('search')) { + $q->where(function ($qq) use ($s) { + $qq->where('name', 'like', "%{$s}%") + ->orWhere('phone', 'like', "%{$s}%") + ->orWhere('email', 'like', "%{$s}%"); + }); + } + return response()->json($q->orderBy('name')->paginate(min((int) $request->query('per_page', 25), 100))); + } + + public function show(Client $client): JsonResponse + { + return response()->json($client->load('vehicles')); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:120', + 'phone' => 'required|string|max:40', + 'phone_alt' => 'nullable|string|max:40', + 'email' => 'nullable|email|max:120', + 'company_name' => 'nullable|string|max:160', + 'type' => 'in:individual,company', + 'status' => 'in:new,active,vip,debtor,blocked,lost', + 'discount_pct' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + $client = Client::create($data + ['type' => 'individual', 'status' => 'active']); + return response()->json($client, 201); + } + + public function update(Request $request, Client $client): JsonResponse + { + $data = $request->validate([ + 'name' => 'sometimes|string|max:120', + 'phone' => 'sometimes|string|max:40', + 'phone_alt' => 'nullable|string|max:40', + 'email' => 'nullable|email|max:120', + 'company_name' => 'nullable|string|max:160', + 'status' => 'in:new,active,vip,debtor,blocked,lost', + 'discount_pct' => 'nullable|numeric|min:0|max:100', + 'notes' => 'nullable|string', + ]); + $client->update($data); + return response()->json($client); + } + + public function destroy(Client $client): JsonResponse + { + $client->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/VehicleApiController.php b/app/Http/Controllers/Api/VehicleApiController.php new file mode 100644 index 0000000..6fdc8fc --- /dev/null +++ b/app/Http/Controllers/Api/VehicleApiController.php @@ -0,0 +1,66 @@ +with('client:id,name,phone'); + if ($s = $request->query('search')) { + $q->where(function ($qq) use ($s) { + $qq->where('plate', 'like', "%{$s}%") + ->orWhere('vin', 'like', "%{$s}%") + ->orWhere('brand', 'like', "%{$s}%") + ->orWhere('model', 'like', "%{$s}%"); + }); + } + return response()->json($q->orderBy('plate')->paginate(min((int) $request->query('per_page', 25), 100))); + } + + public function show(Vehicle $vehicle): JsonResponse + { + return response()->json($vehicle->load(['client', 'workOrders' => fn ($q) => $q->latest()->limit(10)])); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'client_id' => 'required|exists:clients,id', + 'plate' => 'required|string|max:30', + 'vin' => 'nullable|string|max:30', + 'brand' => 'nullable|string|max:60', + 'model' => 'nullable|string|max:60', + 'year' => 'nullable|integer|min:1900|max:2100', + 'mileage' => 'nullable|integer|min:0', + 'fuel' => 'nullable|string|max:30', + 'engine' => 'nullable|string|max:60', + 'gearbox' => 'nullable|string|max:30', + 'color' => 'nullable|string|max:30', + 'notes' => 'nullable|string', + ]); + $v = Vehicle::create($data); + return response()->json($v, 201); + } + + public function update(Request $request, Vehicle $vehicle): JsonResponse + { + $data = $request->validate([ + 'plate' => 'sometimes|string|max:30', + 'mileage' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + $vehicle->update($data); + return response()->json($vehicle); + } + + public function destroy(Vehicle $vehicle): JsonResponse + { + $vehicle->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/WorkOrderApiController.php b/app/Http/Controllers/Api/WorkOrderApiController.php new file mode 100644 index 0000000..61c834c --- /dev/null +++ b/app/Http/Controllers/Api/WorkOrderApiController.php @@ -0,0 +1,73 @@ +with(['client:id,name,phone', 'vehicle:id,plate,brand,model']); + if ($status = $request->query('status')) { + $q->where('status', $status); + } + if ($s = $request->query('search')) { + $q->where(function ($qq) use ($s) { + $qq->where('number', 'like', "%{$s}%") + ->orWhereHas('vehicle', fn ($v) => $v->where('plate', 'like', "%{$s}%")); + }); + } + return response()->json($q->latest()->paginate(min((int) $request->query('per_page', 25), 100))); + } + + public function show(WorkOrder $workOrder): JsonResponse + { + return response()->json($workOrder->load(['client', 'vehicle', 'master', 'works', 'parts', 'payments'])); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'client_id' => 'required|exists:clients,id', + 'vehicle_id' => 'required|exists:vehicles,id', + 'master_id' => 'nullable|exists:users,id', + 'opened_at' => 'nullable|date', + 'mileage_in' => 'nullable|integer|min:0', + 'complaint' => 'nullable|string', + 'status' => 'in:new,diagnosis,agreement,approved,in_work,awaiting_parts,ready,done,cancelled', + ]); + $tenantId = app(TenantManager::class)->currentId(); + $data['number'] = WorkOrder::generateNumber($tenantId); + $data['opened_at'] ??= now(); + $data['status'] ??= 'new'; + $data['pay_status'] = 'unpaid'; + $data['discount_pct'] = 0; + $data['total'] = 0; + $wo = WorkOrder::create($data); + return response()->json($wo, 201); + } + + public function update(Request $request, WorkOrder $workOrder): JsonResponse + { + $data = $request->validate([ + 'status' => 'sometimes|in:new,diagnosis,agreement,approved,in_work,awaiting_parts,ready,done,cancelled', + 'mileage_out' => 'nullable|integer|min:0', + 'diagnosis' => 'nullable|string', + 'recommendations' => 'nullable|string', + 'discount_pct' => 'nullable|numeric|min:0|max:100', + ]); + $workOrder->update($data); + $workOrder->recalcTotal(); + return response()->json($workOrder->fresh()); + } + + public function destroy(WorkOrder $workOrder): JsonResponse + { + $workOrder->delete(); + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Middleware/EnsureTokenMatchesTenant.php b/app/Http/Middleware/EnsureTokenMatchesTenant.php new file mode 100644 index 0000000..aef6512 --- /dev/null +++ b/app/Http/Middleware/EnsureTokenMatchesTenant.php @@ -0,0 +1,34 @@ +user(); + $tenant = app(TenantManager::class)->current(); + + if (! $user || ! $tenant) { + throw new AccessDeniedHttpException('Tenant context required.'); + } + + if ($user->company_id !== $tenant->id) { + throw new AccessDeniedHttpException('Token does not belong to this tenant.'); + } + + return $next($request); + } +} diff --git a/app/Models/Central/SuperAdmin.php b/app/Models/Central/SuperAdmin.php index 842b672..ae5c54d 100644 --- a/app/Models/Central/SuperAdmin.php +++ b/app/Models/Central/SuperAdmin.php @@ -2,21 +2,27 @@ namespace App\Models\Central; +use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication; +use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery; +use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; -class SuperAdmin extends Authenticatable implements FilamentUser +class SuperAdmin extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication { - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; protected $table = 'super_admins'; protected $fillable = [ 'name', 'email', 'password', 'is_active', 'last_login_at', + 'email_authentication_at', + 'app_authentication_secret', 'app_authentication_recovery_codes', ]; protected $hidden = [ @@ -28,8 +34,11 @@ class SuperAdmin extends Authenticatable implements FilamentUser return [ 'email_verified_at' => 'datetime', 'last_login_at' => 'datetime', + 'email_authentication_at' => 'datetime', 'password' => 'hashed', 'is_active' => 'boolean', + 'app_authentication_secret' => 'encrypted', + 'app_authentication_recovery_codes' => 'encrypted:array', ]; } @@ -37,4 +46,41 @@ class SuperAdmin extends Authenticatable implements FilamentUser { return $panel->getId() === 'central' && $this->is_active; } + + public function hasEmailAuthentication(): bool + { + return $this->email_authentication_at !== null; + } + + public function toggleEmailAuthentication(bool $condition): void + { + $this->forceFill([ + 'email_authentication_at' => $condition ? now() : null, + ])->saveQuietly(); + } + + public function getAppAuthenticationSecret(): ?string + { + return $this->app_authentication_secret; + } + + public function saveAppAuthenticationSecret(?string $secret): void + { + $this->forceFill(['app_authentication_secret' => $secret])->saveQuietly(); + } + + public function getAppAuthenticationHolderName(): string + { + return $this->email; + } + + public function getAppAuthenticationRecoveryCodes(): ?array + { + return $this->app_authentication_recovery_codes; + } + + public function saveAppAuthenticationRecoveryCodes(?array $codes): void + { + $this->forceFill(['app_authentication_recovery_codes' => $codes])->saveQuietly(); + } } diff --git a/app/Models/Tenant/User.php b/app/Models/Tenant/User.php index 404e771..6075b08 100644 --- a/app/Models/Tenant/User.php +++ b/app/Models/Tenant/User.php @@ -3,12 +3,16 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; +use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication; +use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery; +use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; use Spatie\Permission\Traits\HasRoles; /** @@ -16,9 +20,9 @@ use Spatie\Permission\Traits\HasRoles; * UNIQUE(company_id, email) — same email can exist in different tenants * as completely separate accounts. */ -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication { - use BelongsToTenant, HasFactory, HasRoles, Notifiable, SoftDeletes; + use BelongsToTenant, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes; /** Spatie Permission scope key matches the team_foreign_key (company_id). */ protected $guard_name = 'web'; @@ -28,6 +32,8 @@ class User extends Authenticatable implements FilamentUser 'role', 'status', 'locale', 'specialization', 'color', 'hourly_rate', 'email_verified_at', 'password', 'last_login_at', + 'email_authentication_at', + 'app_authentication_secret', 'app_authentication_recovery_codes', ]; protected $hidden = [ @@ -39,7 +45,10 @@ class User extends Authenticatable implements FilamentUser return [ 'email_verified_at' => 'datetime', 'last_login_at' => 'datetime', + 'email_authentication_at' => 'datetime', 'password' => 'hashed', + 'app_authentication_secret' => 'encrypted', + 'app_authentication_recovery_codes' => 'encrypted:array', ]; } @@ -53,4 +62,41 @@ class User extends Authenticatable implements FilamentUser { return $this->role === 'admin'; } + + public function hasEmailAuthentication(): bool + { + return $this->email_authentication_at !== null; + } + + public function toggleEmailAuthentication(bool $condition): void + { + $this->forceFill([ + 'email_authentication_at' => $condition ? now() : null, + ])->saveQuietly(); + } + + public function getAppAuthenticationSecret(): ?string + { + return $this->app_authentication_secret; + } + + public function saveAppAuthenticationSecret(?string $secret): void + { + $this->forceFill(['app_authentication_secret' => $secret])->saveQuietly(); + } + + public function getAppAuthenticationHolderName(): string + { + return $this->email; + } + + public function getAppAuthenticationRecoveryCodes(): ?array + { + return $this->app_authentication_recovery_codes; + } + + public function saveAppAuthenticationRecoveryCodes(?array $codes): void + { + $this->forceFill(['app_authentication_recovery_codes' => $codes])->saveQuietly(); + } } diff --git a/app/Providers/Filament/CentralPanelProvider.php b/app/Providers/Filament/CentralPanelProvider.php index bf79247..1011de7 100644 --- a/app/Providers/Filament/CentralPanelProvider.php +++ b/app/Providers/Filament/CentralPanelProvider.php @@ -37,6 +37,11 @@ class CentralPanelProvider extends PanelProvider ]) ->authGuard('central') ->authPasswordBroker('super_admins') + ->multiFactorAuthentication([ + \Filament\Auth\MultiFactor\App\AppAuthentication::make(), + \Filament\Auth\MultiFactor\Email\EmailAuthentication::make(), + ]) + ->profile() ->discoverResources(in: app_path('Filament/Central/Resources'), for: 'App\\Filament\\Central\\Resources') ->discoverPages(in: app_path('Filament/Central/Pages'), for: 'App\\Filament\\Central\\Pages') ->pages([ diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 5c98b27..f10f346 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -41,6 +41,11 @@ class TenantPanelProvider extends PanelProvider ->databaseNotifications() ->databaseNotificationsPolling('30s') ->globalSearchKeyBindings(['command+k', 'ctrl+k']) + ->multiFactorAuthentication([ + \Filament\Auth\MultiFactor\App\AppAuthentication::make(), + \Filament\Auth\MultiFactor\Email\EmailAuthentication::make(), + ]) + ->profile() ->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources') ->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages') ->pages([ diff --git a/app/Services/CsvImportExport.php b/app/Services/CsvImportExport.php new file mode 100644 index 0000000..6b2a8d3 --- /dev/null +++ b/app/Services/CsvImportExport.php @@ -0,0 +1,184 @@ +stream('clienti-' . date('Y-m-d') . '.csv', self::CLIENT_COLUMNS, function ($out) { + Client::orderBy('name')->chunk(500, function ($rows) use ($out) { + foreach ($rows as $row) { + fputcsv($out, [ + $row->name, $row->phone, $row->phone_alt, $row->email, $row->company_name, + $row->type, $row->status, $row->source, $row->marketing_channel, + $row->discount_pct, $row->balance, $row->notes, + ]); + } + }); + }); + } + + public function exportVehicles(): StreamedResponse + { + return $this->stream('automobile-' . date('Y-m-d') . '.csv', self::VEHICLE_COLUMNS, function ($out) { + Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) { + foreach ($rows as $row) { + fputcsv($out, [ + $row->plate, $row->vin, $row->brand, $row->model, $row->year, + $row->engine, $row->gearbox, $row->fuel, $row->mileage, + $row->color, $row->notes, $row->client?->phone, + ]); + } + }); + }); + } + + /** + * Import clients from CSV. Returns ['imported' => N, 'skipped' => M, 'errors' => [...]]. + */ + public function importClients(string $absolutePath): array + { + return $this->import($absolutePath, self::CLIENT_COLUMNS, function (array $row) { + $phone = $row['phone'] ?? null; + if (empty($phone) || empty($row['name'])) { + return ['skip', 'phone & name required']; + } + $existing = Client::where('phone', $phone)->first(); + if ($existing) { + return ['skip', 'phone already exists']; + } + Client::create([ + 'name' => $row['name'], + 'phone' => $phone, + 'phone_alt' => $row['phone_alt'] ?? null, + 'email' => $row['email'] ?? null, + 'company_name' => $row['company_name'] ?? null, + 'type' => $row['type'] ?? 'individual', + 'status' => $row['status'] ?? 'active', + 'source' => $row['source'] ?? null, + 'marketing_channel' => $row['marketing_channel'] ?? null, + 'discount_pct' => (float) ($row['discount_pct'] ?? 0), + 'balance' => (float) ($row['balance'] ?? 0), + 'notes' => $row['notes'] ?? null, + ]); + return ['ok', null]; + }); + } + + public function importVehicles(string $absolutePath): array + { + return $this->import($absolutePath, self::VEHICLE_COLUMNS, function (array $row) { + if (empty($row['plate'])) { + return ['skip', 'plate required']; + } + $client = null; + if (! empty($row['client_phone'])) { + $client = Client::where('phone', $row['client_phone'])->first(); + } + if (! $client) { + return ['skip', 'client_phone not found']; + } + if (Vehicle::where('plate', $row['plate'])->exists()) { + return ['skip', 'plate already exists']; + } + Vehicle::create([ + 'client_id' => $client->id, + 'plate' => $row['plate'], + 'vin' => $row['vin'] ?? null, + 'brand' => $row['brand'] ?? null, + 'model' => $row['model'] ?? null, + 'year' => (int) ($row['year'] ?? 0) ?: null, + 'engine' => $row['engine'] ?? null, + 'gearbox' => $row['gearbox'] ?? null, + 'fuel' => $row['fuel'] ?? null, + 'mileage' => (int) ($row['mileage'] ?? 0) ?: null, + 'color' => $row['color'] ?? null, + 'notes' => $row['notes'] ?? null, + ]); + return ['ok', null]; + }); + } + + private function stream(string $filename, array $columns, \Closure $writer): StreamedResponse + { + return response()->streamDownload(function () use ($columns, $writer) { + $out = fopen('php://output', 'w'); + // UTF-8 BOM so Excel opens with correct diacritics. + fwrite($out, "\xEF\xBB\xBF"); + fputcsv($out, $columns); + $writer($out); + fclose($out); + }, $filename, ['Content-Type' => 'text/csv; charset=utf-8']); + } + + private function import(string $absolutePath, array $columns, \Closure $rowHandler): array + { + if (! file_exists($absolutePath)) { + return ['imported' => 0, 'skipped' => 0, 'errors' => ['file not found']]; + } + $h = fopen($absolutePath, 'r'); + // Strip UTF-8 BOM if present. + $bom = fread($h, 3); + if ($bom !== "\xEF\xBB\xBF") { + rewind($h); + } + $header = fgetcsv($h); + if (! $header) { + fclose($h); + return ['imported' => 0, 'skipped' => 0, 'errors' => ['empty file']]; + } + $header = array_map('trim', $header); + $imported = 0; + $skipped = 0; + $errors = []; + $line = 1; + DB::beginTransaction(); + try { + while (($cells = fgetcsv($h)) !== false) { + $line++; + if (count($cells) !== count($header)) { + $errors[] = "L{$line}: column count mismatch"; + $skipped++; + continue; + } + $row = array_combine($header, array_map(fn ($c) => $c === '' ? null : $c, $cells)); + [$status, $err] = $rowHandler($row); + if ($status === 'ok') { + $imported++; + } else { + $skipped++; + if ($err) $errors[] = "L{$line}: {$err}"; + } + } + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + $errors[] = 'fatal: ' . $e->getMessage(); + } + fclose($h); + return ['imported' => $imported, 'skipped' => $skipped, 'errors' => array_slice($errors, 0, 50)]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 40d8990..7faf100 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', channels: __DIR__.'/../routes/channels.php', health: '/up', @@ -31,6 +32,13 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Http\Middleware\CheckTenantStatus::class, \App\Http\Middleware\SetLocale::class, ]); + + // API routes also need tenant resolution by host so the same + // Eloquent TenantScope works. + $middleware->api(prepend: [ + \App\Http\Middleware\ResolveTenant::class, + \App\Http\Middleware\CheckTenantStatus::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..c1924a2 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,22 @@ + explode(',', (string) env( + 'SANCTUM_STATEFUL_DOMAINS', + 'localhost,127.0.0.1' + )), + + 'guard' => ['web'], + + 'expiration' => null, + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], +]; diff --git a/database/migrations/2026_05_08_000010_create_personal_access_tokens_table.php b/database/migrations/2026_05_08_000010_create_personal_access_tokens_table.php new file mode 100644 index 0000000..21e3c4c --- /dev/null +++ b/database/migrations/2026_05_08_000010_create_personal_access_tokens_table.php @@ -0,0 +1,26 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_05_08_000020_add_mfa_columns_to_users.php b/database/migrations/2026_05_08_000020_add_mfa_columns_to_users.php new file mode 100644 index 0000000..0baf944 --- /dev/null +++ b/database/migrations/2026_05_08_000020_add_mfa_columns_to_users.php @@ -0,0 +1,32 @@ +timestamp('email_authentication_at')->nullable()->after('locale'); + $table->text('app_authentication_secret')->nullable()->after('email_authentication_at'); + $table->json('app_authentication_recovery_codes')->nullable()->after('app_authentication_secret'); + }); + + Schema::table('super_admins', function (Blueprint $table) { + $table->timestamp('email_authentication_at')->nullable()->after('is_active'); + $table->text('app_authentication_secret')->nullable()->after('email_authentication_at'); + $table->json('app_authentication_recovery_codes')->nullable()->after('app_authentication_secret'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['email_authentication_at', 'app_authentication_secret', 'app_authentication_recovery_codes']); + }); + Schema::table('super_admins', function (Blueprint $table) { + $table->dropColumn(['email_authentication_at', 'app_authentication_secret', 'app_authentication_recovery_codes']); + }); + } +}; diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index cf938ed..a1c5f99 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -36,5 +36,17 @@ fi # Storage symlink (idempotent) php artisan storage:link --no-interaction 2>/dev/null || true +# Background scheduler — fires every minute. Drives backup:tenants and other cron jobs. +# Skipped if RUN_SCHEDULER=false (e.g., when running multiple replicas). +if [ "${RUN_SCHEDULER:-true}" = "true" ]; then + echo "[entrypoint] Starting Laravel scheduler in background..." + ( + while true; do + php artisan schedule:run --no-interaction >> storage/logs/scheduler.log 2>&1 || true + sleep 60 + done + ) & +fi + echo "[entrypoint] Starting: $@" exec "$@" diff --git a/resources/views/filament/tenant/pages/api-tokens.blade.php b/resources/views/filament/tenant/pages/api-tokens.blade.php new file mode 100644 index 0000000..d2438fe --- /dev/null +++ b/resources/views/filament/tenant/pages/api-tokens.blade.php @@ -0,0 +1,87 @@ + + + + @if ($newToken) +
+

⚠ Token-ul tău nou (afișat o singură dată):

+
+ {{ $newToken }} + +
+ +
+ @endif + +
+ @php $tokens = $this->getTokens(); @endphp + @if (empty($tokens)) +
Niciun token generat. Folosește butonul „Generează token" de sus.
+ @else + + + + + + + + + + + + @foreach ($tokens as $t) + + + + + + + + @endforeach + +
NumeFolosit ultima datăCreatPermisiuni
{{ $t['name'] }}{{ $t['last_used_at'] }}{{ $t['created_at'] }}{{ $t['abilities'] }} + +
+ @endif +
+ +
+ Cum folosești API-ul: +
    +
  1. Obține token cu POST /api/v1/login cu email+password JSON
  2. +
  3. Sau generează unul aici (recomandat pentru integrări permanente)
  4. +
  5. Adaugă header Authorization: Bearer <token> la fiecare request
  6. +
  7. Endpoints disponibile: /api/v1/clients, /api/v1/vehicles, /api/v1/work-orders (GET, POST, PATCH, DELETE)
  8. +
  9. Tot apelul trebuie făcut pe subdomain-ul tău: https://{{ tenant()?->slug ?? 'YOURSLUG' }}.service.mir.md/api/v1/...
  10. +
+
+
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..75e1c2f --- /dev/null +++ b/routes/api.php @@ -0,0 +1,20 @@ +prefix('api/v1')->group(function () { + Route::post('/login', [ApiAuthController::class, 'login']); + + Route::middleware(['auth:sanctum', \App\Http\Middleware\EnsureTokenMatchesTenant::class])->group(function () { + Route::get('/me', [ApiAuthController::class, 'me']); + Route::post('/logout', [ApiAuthController::class, 'logout']); + + Route::apiResource('clients', ClientApiController::class); + Route::apiResource('vehicles', VehicleApiController::class); + Route::apiResource('work-orders', WorkOrderApiController::class); + }); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..3e6941e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,20 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Daily tenant backups at 03:00 — auto-rotates after 14 days. +ScheduleFacade::command('backup:tenants --keep=14') + ->dailyAt('03:00') + ->withoutOverlapping() + ->onOneServer(); + +// AI chat cleanup — keep tokens spend in check. +ScheduleFacade::command('queue:prune-batches --hours=48')->daily(); +ScheduleFacade::command('queue:prune-failed --hours=72')->daily();