diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 729336a0..519665d4 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -94,6 +94,23 @@ class AuthController extends Controller if (! $user->is_active) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + + // Косяк 05: неподтверждённая почта — это НЕ блокировка. Новый клиент + // создаётся is_active=false до ввода кода из письма. Не пугаем + // «Аккаунт заблокирован», а зовём подтвердить почту. + if ($user->email_verified_at === null) { + $this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(), + 'email_not_confirmed'); + + $msg = 'Подтвердите почту — мы отправили код на '.$user->email.'.'; + + return response()->json([ + 'message' => $msg, + 'errors' => ['email' => [$msg]], + 'email_not_confirmed' => true, + ], 422); + } + $this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(), 'account_locked'); diff --git a/app/resources/js/views/auth/LoginView.vue b/app/resources/js/views/auth/LoginView.vue index 10a39e2c..0ed6e988 100644 --- a/app/resources/js/views/auth/LoginView.vue +++ b/app/resources/js/views/auth/LoginView.vue @@ -34,6 +34,14 @@ async function handleSubmit() { }); await router.push(response.requires_2fa ? '/2fa' : '/dashboard'); } catch (error: unknown) { + // Косяк 05: почта не подтверждена — не пугаем «заблокирован», уводим на + // подтверждение почты (там кнопка «Отправить повторно»), email переносим в store. + const data = (error as { response?: { data?: { email_not_confirmed?: boolean } } }).response?.data; + if (data?.email_not_confirmed) { + auth.pendingEmail = email.value; + await router.push('/confirm-email'); + return; + } const validationErrors = extractValidationErrors(error); if (validationErrors) { errors.value = validationErrors; diff --git a/app/tests/Feature/Auth/LoginUnconfirmedEmailTest.php b/app/tests/Feature/Auth/LoginUnconfirmedEmailTest.php new file mode 100644 index 00000000..bfe6fac4 --- /dev/null +++ b/app/tests/Feature/Auth/LoginUnconfirmedEmailTest.php @@ -0,0 +1,53 @@ +tenant = Tenant::factory()->create(); +}); + +test('косяк 05: вход с неподтверждённой почтой зовёт подтвердить, а не «заблокирован»', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'unconfirmed@example.ru', + 'password_hash' => Hash::make('right-pass-123'), + 'is_active' => false, + 'email_verified_at' => null, + ]); + + $response = $this->postJson('/api/auth/login', [ + 'email' => 'unconfirmed@example.ru', + 'password' => 'right-pass-123', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('email_not_confirmed', true); + expect($response->json('errors.email.0'))->toContain('одтвердите почту') + ->and($response->json('errors.email.0'))->not->toContain('аблокирован'); +}); + +test('косяк 05: подтверждённая почта но is_active=false остаётся «Аккаунт заблокирован»', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'really-blocked@example.ru', + 'password_hash' => Hash::make('right-pass-123'), + 'is_active' => false, + 'email_verified_at' => now(), + ]); + + $response = $this->postJson('/api/auth/login', [ + 'email' => 'really-blocked@example.ru', + 'password' => 'right-pass-123', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.email.0', 'Аккаунт заблокирован.'); + expect($response->json('email_not_confirmed'))->toBeNull(); +}); diff --git a/app/tests/Frontend/LoginView.spec.ts b/app/tests/Frontend/LoginView.spec.ts index c107c383..d6ceee39 100644 --- a/app/tests/Frontend/LoginView.spec.ts +++ b/app/tests/Frontend/LoginView.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { mount } from '@vue/test-utils'; +import { describe, it, expect, vi } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import { createRouter, createMemoryHistory } from 'vue-router'; @@ -81,6 +81,34 @@ describe('LoginView.vue', () => { expect(alert.text()).toContain('Слишком много попыток'); }); + it('косяк 05: при email_not_confirmed уводит на /confirm-email и кладёт email в store', async () => { + const pinia = createPinia(); + setActivePinia(pinia); + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/login', name: 'login', component: LoginView }, + { path: '/register', name: 'register', component: { template: '
stub
' } }, + { path: '/forgot', name: 'forgot', component: { template: '
stub
' } }, + { path: '/confirm-email', name: 'confirm-email', component: { template: '
confirm
' } }, + ], + }); + await router.push('/login'); + await router.isReady(); + const wrapper = mount(LoginView, { global: { plugins: [pinia, createVuetify(), router] } }); + + const auth = useAuthStore(); + auth.login = vi.fn().mockRejectedValue({ response: { data: { email_not_confirmed: true } } }); + + await wrapper.find('input[type="email"]').setValue('unconfirmed@example.ru'); + await wrapper.find('input[type="password"]').setValue('password12'); + await wrapper.find('form').trigger('submit.prevent'); + await flushPromises(); + + expect(router.currentRoute.value.path).toBe('/confirm-email'); + expect(auth.pendingEmail).toBe('unconfirmed@example.ru'); + }); + it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => { const wrapper = await mountLoginView(); const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));