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
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Services\TenantBackupService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BackupAllTenantsCommand extends Command
|
||||
{
|
||||
protected $signature = 'backup:tenants
|
||||
{--keep=14 : How many daily backups to retain per tenant}
|
||||
{--slug= : Backup only one tenant by slug}';
|
||||
|
||||
protected $description = 'Export ZIP backup for every active tenant. Stored in storage/app/backups/{date}/';
|
||||
|
||||
public function handle(TenantBackupService $service): int
|
||||
{
|
||||
$query = Company::query()->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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class ApiTokens extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
|
||||
|
||||
protected static ?string $navigationLabel = 'API Tokens';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
|
||||
|
||||
protected static ?int $navigationSort = 95;
|
||||
|
||||
protected static ?string $title = 'API Tokens';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.api-tokens';
|
||||
|
||||
public ?string $newToken = null;
|
||||
|
||||
public function getTokens(): array
|
||||
{
|
||||
$u = auth()->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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Tenant\User;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ApiAuthController
|
||||
{
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Tenant\Client;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ClientApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$q = Client::query();
|
||||
if ($s = $request->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VehicleApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$q = Vehicle::query()->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WorkOrderApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$q = WorkOrder::query()->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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Tenancy\TenantManager;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* Defense in depth: even though Sanctum auth + TenantScope should isolate
|
||||
* tenants, this middleware EXPLICITLY rejects any request where the
|
||||
* authenticated user's company_id does not match the resolved tenant.
|
||||
*
|
||||
* Stops the entire class of "stolen token used on wrong subdomain" attacks.
|
||||
*/
|
||||
class EnsureTokenMatchesTenant
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Plain-CSV import/export for the most-imported entities (Clients, Vehicles).
|
||||
* No external package — uses fputcsv / fgetcsv with explicit BOM for Excel UTF-8.
|
||||
*/
|
||||
class CsvImportExport
|
||||
{
|
||||
public const CLIENT_COLUMNS = [
|
||||
'name', 'phone', 'phone_alt', 'email', 'company_name',
|
||||
'type', 'status', 'source', 'marketing_channel',
|
||||
'discount_pct', 'balance', 'notes',
|
||||
];
|
||||
|
||||
public const VEHICLE_COLUMNS = [
|
||||
'plate', 'vin', 'brand', 'model', 'year',
|
||||
'engine', 'gearbox', 'fuel', 'mileage',
|
||||
'color', 'notes', 'client_phone',
|
||||
];
|
||||
|
||||
public function exportClients(): StreamedResponse
|
||||
{
|
||||
return $this->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)];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
//
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
'stateful' => 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,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<x-filament-panels::page>
|
||||
<style>
|
||||
.at-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }
|
||||
.dark .at-card { background: #1f2937; border-color: #374151; }
|
||||
.at-token {
|
||||
background: #1f2937; color: #fbbf24;
|
||||
font-family: ui-monospace, monospace; font-size: 12px;
|
||||
padding: 12px; border-radius: 6px; word-break: break-all;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.at-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.at-table th, .at-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f3f4f6; }
|
||||
.dark .at-table th, .dark .at-table td { border-color: #374151; }
|
||||
.at-table th { font-weight: 600; color: #6b7280; font-size: 11px; text-transform: uppercase; }
|
||||
.at-empty { padding: 32px; text-align: center; color: #9ca3af; font-size: 13px; }
|
||||
.at-revoke { background: #ef4444; color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; border: none; }
|
||||
.at-revoke:hover { background: #dc2626; }
|
||||
.at-doc { background: #eff6ff; border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 4px; font-size: 12px; line-height: 1.6; }
|
||||
.dark .at-doc { background: #1e3a8a40; }
|
||||
.at-doc code { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 11px; }
|
||||
.dark .at-doc code { background: rgba(255,255,255,.1); }
|
||||
</style>
|
||||
|
||||
@if ($newToken)
|
||||
<div class="at-card" style="border-color: #f59e0b; border-width: 2px;">
|
||||
<h3 style="font-weight:600;font-size:15px;margin-bottom:6px;">⚠ Token-ul tău nou (afișat o singură dată):</h3>
|
||||
<div class="at-token" x-data="{ copied: false }">
|
||||
<span x-ref="t">{{ $newToken }}</span>
|
||||
<button type="button"
|
||||
@click="navigator.clipboard.writeText($refs.t.innerText); copied = true; setTimeout(() => copied = false, 2000)"
|
||||
style="margin-left:12px;background:#3b82f6;color:white;padding:4px 10px;border:none;border-radius:4px;cursor:pointer;font-size:11px;">
|
||||
<span x-show="!copied">📋 Copiază</span>
|
||||
<span x-show="copied" x-cloak>✓ Copiat</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" wire:click="dismissNew" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;">
|
||||
Am salvat token-ul, ascunde-l
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="at-card" style="margin-top:16px;">
|
||||
@php $tokens = $this->getTokens(); @endphp
|
||||
@if (empty($tokens))
|
||||
<div class="at-empty">Niciun token generat. Folosește butonul „Generează token" de sus.</div>
|
||||
@else
|
||||
<table class="at-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nume</th>
|
||||
<th>Folosit ultima dată</th>
|
||||
<th>Creat</th>
|
||||
<th>Permisiuni</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($tokens as $t)
|
||||
<tr>
|
||||
<td><b>{{ $t['name'] }}</b></td>
|
||||
<td>{{ $t['last_used_at'] }}</td>
|
||||
<td>{{ $t['created_at'] }}</td>
|
||||
<td><code style="font-size:11px;">{{ $t['abilities'] }}</code></td>
|
||||
<td>
|
||||
<button type="button"
|
||||
wire:click="deleteToken({{ $t['id'] }})"
|
||||
wire:confirm="Revoci token-ul „{{ $t['name'] }}"? Apelurile API vor eșua imediat."
|
||||
class="at-revoke">Revocă</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="at-doc" style="margin-top:16px;">
|
||||
<b>Cum folosești API-ul:</b>
|
||||
<ol style="margin:8px 0 0 20px;">
|
||||
<li>Obține token cu <code>POST /api/v1/login</code> cu <code>email</code>+<code>password</code> JSON</li>
|
||||
<li>Sau generează unul aici (recomandat pentru integrări permanente)</li>
|
||||
<li>Adaugă header <code>Authorization: Bearer <token></code> la fiecare request</li>
|
||||
<li>Endpoints disponibile: <code>/api/v1/clients</code>, <code>/api/v1/vehicles</code>, <code>/api/v1/work-orders</code> (GET, POST, PATCH, DELETE)</li>
|
||||
<li>Tot apelul trebuie făcut pe subdomain-ul tău: <code>https://{{ tenant()?->slug ?? 'YOURSLUG' }}.service.mir.md/api/v1/...</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApiAuthController;
|
||||
use App\Http\Controllers\Api\ClientApiController;
|
||||
use App\Http\Controllers\Api\VehicleApiController;
|
||||
use App\Http\Controllers\Api\WorkOrderApiController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['api'])->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);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule as ScheduleFacade;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->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();
|
||||
|
||||
Reference in New Issue
Block a user