diff --git a/app/Domains/Auth/Controllers/ScannerLoginController.php b/app/Domains/Auth/Controllers/ScannerLoginController.php new file mode 100644 index 0000000..3a6b446 --- /dev/null +++ b/app/Domains/Auth/Controllers/ScannerLoginController.php @@ -0,0 +1,42 @@ +validated(); + $user = $this->passwordLoginService->authenticateScanner( + $credentials['email'], + $credentials['password'], + $request->ip(), + $request->userAgent(), + ); + + $expirationMinutes = (int) config('sanctum.expiration'); + $token = $user->createToken( + 'scanner-token', + ['scanner'], + now()->addMinutes($expirationMinutes), + )->plainTextToken; + + return response()->json([ + 'code' => 'auth.login_success', + 'message' => __('api.auth.login_success'), + 'token' => $token, + 'token_type' => 'Bearer', + 'user' => UserResource::make($user), + ]); + } +} diff --git a/app/Domains/Auth/Controllers/ScannerMeController.php b/app/Domains/Auth/Controllers/ScannerMeController.php new file mode 100644 index 0000000..6acc33c --- /dev/null +++ b/app/Domains/Auth/Controllers/ScannerMeController.php @@ -0,0 +1,24 @@ +user(); + + return ScannerMeResource::make($this->scannerContextService->load($user)); + } +} diff --git a/app/Domains/Auth/Requests/ScannerLoginRequest.php b/app/Domains/Auth/Requests/ScannerLoginRequest.php new file mode 100644 index 0000000..c699282 --- /dev/null +++ b/app/Domains/Auth/Requests/ScannerLoginRequest.php @@ -0,0 +1,5 @@ + */ + public function toArray(Request $request): array + { + return [ + 'user' => UserResource::make($this->resource), + 'tenant' => TenantResource::make($this->tenant), + ]; + } +} diff --git a/app/Domains/Auth/Services/PasswordLoginService.php b/app/Domains/Auth/Services/PasswordLoginService.php index 462d9f7..6915322 100644 --- a/app/Domains/Auth/Services/PasswordLoginService.php +++ b/app/Domains/Auth/Services/PasswordLoginService.php @@ -62,6 +62,29 @@ class PasswordLoginService ); } + /** + * Authenticate a tenant-bound scanner without requiring its tenant code. + * + * @throws AccountLockedException + * @throws ValidationException + */ + public function authenticateScanner( + string $email, + string $password, + ?string $ipAddress, + ?string $userAgent, + ): User { + return $this->authenticateUser( + $email, + $password, + null, + $ipAddress, + $userAgent, + RoleCode::Scanner, + true, + ); + } + private function authenticateUser( string $email, string $password, @@ -210,7 +233,7 @@ class PasswordLoginService } catch (\Throwable $e) { Log::error('Failed to trigger reset password on account lock', [ 'user_id' => $user->id, - 'exception' => $e + 'exception' => $e, ]); } } diff --git a/app/Domains/Auth/Services/ScannerContextService.php b/app/Domains/Auth/Services/ScannerContextService.php new file mode 100644 index 0000000..970fcee --- /dev/null +++ b/app/Domains/Auth/Services/ScannerContextService.php @@ -0,0 +1,25 @@ +tenant() + ->with([ + 'menues' => fn ($query) => $query->whereHas( + 'roles', + fn ($query) => $query->where('codigo', RoleCode::Scanner->value) + ), + ]) + ->firstOrFail(); + + $user->setRelation('tenant', $tenant); + + return $user; + } +} diff --git a/app/Domains/Auth/routes/api.php b/app/Domains/Auth/routes/api.php index 43add70..5b25226 100644 --- a/app/Domains/Auth/routes/api.php +++ b/app/Domains/Auth/routes/api.php @@ -25,3 +25,4 @@ Route::middleware('auth:sanctum')->get('/me', MeController::class); Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class); require __DIR__.'/adminapp.php'; +require __DIR__.'/scanner.php'; diff --git a/app/Domains/Auth/routes/scanner.php b/app/Domains/Auth/routes/scanner.php new file mode 100644 index 0000000..d4894eb --- /dev/null +++ b/app/Domains/Auth/routes/scanner.php @@ -0,0 +1,11 @@ +group(function (): void { + Route::post('login', ScannerLoginController::class)->middleware('throttle:login'); + Route::middleware(['auth:sanctum', 'scanner.tenant']) + ->get('me', ScannerMeController::class); +}); diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 3c09308..9ab6d3b 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -29,6 +29,13 @@ class MenuSeeder extends Seeder 'content_type' => Menu::CONTENT_TYPE_DYNAMIC, 'route' => '/', ], + ['code' => 'scanner.inicio', 'label' => 'Inicio', 'route' => '/scanner/inicio'], + ['code' => 'scanner.scan', 'label' => 'Escanear', 'route' => '/scanner/scan'], + [ + 'code' => 'scanner.detail', + 'label' => 'Detalle', + 'route' => '/scanner/detail/:id', + ], [ 'code' => 'adminapp.event', 'label' => 'Eventos', @@ -219,8 +226,11 @@ class MenuSeeder extends Seeder ->where('code', 'main.adminapp') ->orWhere('parent_menu_code', 'main.adminapp') ->pluck('code'); + $scannerMenuCodes = Menu::query() + ->where('code', 'like', 'scanner.%') + ->pluck('code'); $userMenuCodes = Menu::query() - ->whereNotIn('code', $adminAppMenuCodes) + ->whereNotIn('code', $adminAppMenuCodes->merge($scannerMenuCodes)) ->pluck('code'); Role::query() @@ -231,6 +241,10 @@ class MenuSeeder extends Seeder ->where('codigo', RoleCode::User->value) ->each(fn (Role $role) => $role->menus()->sync($userMenuCodes)); + Role::query() + ->where('codigo', RoleCode::Scanner->value) + ->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes)); + $tenants = Tenant::all(); $allMenus = Menu::pluck('code')->toArray(); diff --git a/tests/Feature/Auth/ScannerLoginControllerTest.php b/tests/Feature/Auth/ScannerLoginControllerTest.php new file mode 100644 index 0000000..805e16c --- /dev/null +++ b/tests/Feature/Auth/ScannerLoginControllerTest.php @@ -0,0 +1,66 @@ +create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + ]); + $user = User::factory()->create([ + 'email' => 'scanner@example.com', + 'password' => Hash::make('secret123'), + 'rol_codigo' => $role->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + + $response = $this->postJson('/api/v1/scanner/login', [ + 'email' => ' SCANNER@EXAMPLE.COM ', + 'password' => 'secret123', + ]); + + $response + ->assertOk() + ->assertJsonPath('user.id', $user->id) + ->assertJsonPath('user.rol_codigo', RoleCode::Scanner->value) + ->assertJsonPath('token_type', 'Bearer'); + + $this->assertSame(['scanner'], $user->tokens()->sole()->abilities); + } + + public function test_it_rejects_a_non_scanner_user(): void + { + $role = Role::query()->create([ + 'codigo' => RoleCode::User->value, + 'nombre' => 'Usuario', + ]); + $user = User::factory()->create([ + 'email' => 'customer@example.com', + 'password' => Hash::make('secret123'), + 'rol_codigo' => $role->codigo, + ]); + + $this->postJson('/api/v1/scanner/login', [ + 'email' => $user->email, + 'password' => 'secret123', + ])->assertUnprocessable()->assertJsonValidationErrors(['email']); + } +} diff --git a/tests/Feature/Auth/ScannerMeControllerTest.php b/tests/Feature/Auth/ScannerMeControllerTest.php new file mode 100644 index 0000000..9c00725 --- /dev/null +++ b/tests/Feature/Auth/ScannerMeControllerTest.php @@ -0,0 +1,61 @@ +create([ + 'codigo' => RoleCode::Scanner->value, + 'nombre' => 'Scanner', + ]); + $tenant = Tenant::query()->create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.test', + ]); + $home = Menu::query()->create([ + 'code' => 'scanner.inicio', + 'label' => 'Inicio', + 'route' => '/scanner/inicio', + ]); + $scan = Menu::query()->create([ + 'code' => 'scanner.scan', + 'label' => 'Escanear', + 'route' => '/scanner/scan', + ]); + $foreign = Menu::query()->create([ + 'code' => 'adminapp.inicio', + 'label' => 'Administración', + 'route' => '/admin/inicio', + ]); + + $scannerRole->menus()->sync([$home->code, $scan->code]); + $tenant->menues()->sync([$home->code, $scan->code, $foreign->code]); + + $user = User::factory()->create([ + 'rol_codigo' => $scannerRole->codigo, + 'tenant_codigo' => $tenant->codigo, + ]); + Sanctum::actingAs($user); + + $this->getJson('/api/v1/scanner/me') + ->assertOk() + ->assertJsonPath('data.user.id', $user->id) + ->assertJsonPath('data.tenant.codigo', $tenant->codigo) + ->assertJsonCount(2, 'data.tenant.menues') + ->assertJsonMissing(['code' => $foreign->code]); + } +} diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index fcf11f0..6f579d0 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -124,8 +124,13 @@ class MenuSeederTest extends TestCase ->where('code', 'main.adminapp') ->orWhere('parent_menu_code', 'main.adminapp') ->pluck('code'); + $scannerMenuCodes = Menu::query() + ->where('code', 'like', 'scanner.%') + ->orderBy('code') + ->pluck('code') + ->all(); $userMenuCodes = Menu::query() - ->whereNotIn('code', $adminAppMenuCodes) + ->whereNotIn('code', [...$adminAppMenuCodes, ...$scannerMenuCodes]) ->orderBy('code') ->pluck('code') ->all(); @@ -153,9 +158,23 @@ class MenuSeederTest extends TestCase $this->assertSame($userMenuCodes, $userRoleMenuCodes); $this->assertNotContains('main.adminapp', $userRoleMenuCodes); $this->assertNotContains('adminapp.catalog', $userRoleMenuCodes); + + $scannerRoleMenuCodes = Role::query() + ->where('codigo', RoleCode::Scanner->value) + ->firstOrFail() + ->menus() + ->orderBy('menues.code') + ->pluck('menues.code') + ->all(); + + $this->assertSame([ + 'scanner.detail', + 'scanner.inicio', + 'scanner.scan', + ], $scannerRoleMenuCodes); $this->assertDatabaseCount( 'roles_menues', - (count($allMenuCodes) * 2) + count($userMenuCodes) + (count($allMenuCodes) * 2) + count($userMenuCodes) + count($scannerMenuCodes) ); }