fix/auth: косяк 05 — неподтверждённая почта зовёт подтвердить, а не «заблокирован»

При входе с email_verified_at=null (новый клиент не ввёл код из письма)
AuthController возвращает дружелюбное «Подтвердите почту — мы отправили код
на ...» с флагом email_not_confirmed, а не пугающее «Аккаунт заблокирован».
Реальная блокировка админом (verified + is_active=false) по-прежнему даёт
«Аккаунт заблокирован». Фронт по флагу уводит на /confirm-email с переносом
email в store (там кнопка «Отправить код повторно»).
TDD: Pest 2 кейса (неподтверждённый vs заблокирован), vitest redirect-кейс,
все GREEN. Проверено глазами на 8000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-24 16:15:06 +03:00
parent 394c97e83e
commit 80de6ecbbd
4 changed files with 108 additions and 2 deletions
@@ -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');
@@ -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;
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->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();
});
+30 -2
View File
@@ -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: '<div>stub</div>' } },
{ path: '/forgot', name: 'forgot', component: { template: '<div>stub</div>' } },
{ path: '/confirm-email', name: 'confirm-email', component: { template: '<div>confirm</div>' } },
],
});
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'));