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:
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user