diff --git a/src/food-market.api/Controllers/AuthSignupController.cs b/src/food-market.api/Controllers/AuthSignupController.cs index 3564ecd..b9880e6 100644 --- a/src/food-market.api/Controllers/AuthSignupController.cs +++ b/src/food-market.api/Controllers/AuthSignupController.cs @@ -42,7 +42,19 @@ public async Task> Signup([FromBody] SignupInput inpu var existing = await _userMgr.FindByEmailAsync(input.Email); if (existing is not null) - return BadRequest(new { error = "Пользователь с таким email уже зарегистрирован." }); + { + // Если AppUser orphan (org удалена / он деактивирован) — реактивируем + // его с новой organization вместо отказа. SuperAdmin'а трогать + // нельзя: у него email тоже email и блокировка не должна давать + // обходить вход. + var isSuperAdmin = await _userMgr.IsInRoleAsync(existing, "SuperAdmin"); + var orgAlive = existing.OrganizationId is not null + && await _db.Organizations.IgnoreQueryFilters() + .AnyAsync(o => o.Id == existing.OrganizationId.Value && !o.IsArchived, ct); + if (isSuperAdmin || orgAlive) + return BadRequest(new { error = "Пользователь с таким email уже зарегистрирован." }); + // Иначе — переиспользуем существующий AppUser ниже. + } // 1. Organization + полный bootstrap tenant-сущностей. var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); @@ -58,25 +70,50 @@ public async Task> Signup([FromBody] SignupInput inpu await _db.SaveChangesAsync(ct); await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct); - // 2. AppUser в роли Identity Admin, привязан к этой организации. - var user = new User + // 2. AppUser — либо новый, либо реактивируем orphan (см. выше). + User user; + if (existing is not null) { - UserName = input.Email.Trim(), - Email = input.Email.Trim(), - EmailConfirmed = true, - FullName = input.OrganizationName.Trim(), - OrganizationId = org.Id, - IsActive = true, - }; - var ur = await _userMgr.CreateAsync(user, input.Password); - if (!ur.Succeeded) - { - // Откат: убираем органзацию чтобы не оставить orphan. - _db.Organizations.Remove(org); - await _db.SaveChangesAsync(ct); - return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) }); + user = existing; + user.OrganizationId = org.Id; + user.IsActive = true; + user.FullName = input.OrganizationName.Trim(); + user.Email = input.Email.Trim(); + user.EmailConfirmed = true; + await _userMgr.UpdateAsync(user); + // Сброс пароля на тот, что юзер ввёл сейчас. + await _userMgr.RemovePasswordAsync(user); + var pwRes = await _userMgr.AddPasswordAsync(user, input.Password); + if (!pwRes.Succeeded) + { + _db.Organizations.Remove(org); + await _db.SaveChangesAsync(ct); + return BadRequest(new { error = string.Join("; ", pwRes.Errors.Select(e => e.Description)) }); + } + // Roles: убедимся что есть Admin. SuperAdmin сюда не попадает (отсечено выше). + if (!await _userMgr.IsInRoleAsync(user, "Admin")) + await _userMgr.AddToRoleAsync(user, "Admin"); + } + else + { + user = new User + { + UserName = input.Email.Trim(), + Email = input.Email.Trim(), + EmailConfirmed = true, + FullName = input.OrganizationName.Trim(), + OrganizationId = org.Id, + IsActive = true, + }; + var ur = await _userMgr.CreateAsync(user, input.Password); + if (!ur.Succeeded) + { + _db.Organizations.Remove(org); + await _db.SaveChangesAsync(ct); + return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) }); + } + await _userMgr.AddToRoleAsync(user, "Admin"); } - await _userMgr.AddToRoleAsync(user, "Admin"); // 3. Owner Employee с системной ролью «Администратор». var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters() diff --git a/src/food-market.public/src/lib/validation.ts b/src/food-market.public/src/lib/validation.ts index a75d1fc..c243a7d 100644 --- a/src/food-market.public/src/lib/validation.ts +++ b/src/food-market.public/src/lib/validation.ts @@ -19,10 +19,13 @@ export function validateEmail(value: string): string | null { } export function validatePassword(value: string): string | null { + // На клиенте — только базовая проверка длины. Все правила сложности + // (заглавные, цифры, спецсимволы) проверяет серверный ASP.NET Identity + // и возвращает конкретное сообщение в общий error-bar формы. Дублировать + // правила на фронте опасно: они расходятся с серверной политикой и + // блокируют ввод валидных паролей. if (!value) return 'Это поле обязательно для заполнения' if (value.length < 8) return 'Пароль должен быть не менее 8 символов' - if (!/[A-ZА-Я]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву' - if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру' return null } diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 394301d..d1d1a4d 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -100,12 +100,9 @@ export function AppLayout() { // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области // даёт периферийный сигнал «я не в своей админке». const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') - // Toast на guard-message (TenantRouteGuard кладёт сообщение в sessionStorage - // перед редиректом — показываем разово). - useEffect(() => { - const msg = sessionStorage.getItem('tenant-guard-msg') - if (msg) { sessionStorage.removeItem('tenant-guard-msg'); alert(msg) } - }, []) + // Tenant-guard tost удалён — раздражал юзера на каждом редиректе. + // Если когда-нибудь понадобится — лучше через ToastContainer, не alert(). + useEffect(() => { sessionStorage.removeItem('tenant-guard-msg') }, []) // Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем // на setup wizard (нельзя пропустить, пока не создадут первую орг). const location2 = useLocation() diff --git a/src/food-market.web/src/components/TenantRouteGuard.tsx b/src/food-market.web/src/components/TenantRouteGuard.tsx index 901516c..6eb3aa3 100644 --- a/src/food-market.web/src/components/TenantRouteGuard.tsx +++ b/src/food-market.web/src/components/TenantRouteGuard.tsx @@ -30,7 +30,6 @@ export function TenantRouteGuard({ children }: { children: React.ReactNode }) { // eslint-disable-next-line no-console console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations', { hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') }) - try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ } navigate('/super-admin/organizations', { replace: true }) } }) diff --git a/src/food-market.web/src/lib/validation.ts b/src/food-market.web/src/lib/validation.ts index adaf6b7..ff1d202 100644 --- a/src/food-market.web/src/lib/validation.ts +++ b/src/food-market.web/src/lib/validation.ts @@ -26,10 +26,12 @@ export function validateEmail(value: string): string | null { } export function validatePassword(value: string): string | null { + // На клиенте — только проверка длины. Сложность (заглавные/цифры/ + // спецсимволы) проверяет серверный Identity и возвращает конкретное + // сообщение в error-bar. Дубль правил на фронте расходится с серверной + // политикой и блокирует ввод валидных паролей. if (!value) return messages.required if (value.length < 8) return messages.passwordShort - if (!/[A-ZА-Я]/.test(value)) return messages.passwordNoUpper - if (!/[0-9]/.test(value)) return messages.passwordNoDigit return null } diff --git a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx index adf77f3..defbae4 100644 --- a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx @@ -125,8 +125,11 @@ export function SuperAdminDashboardPage() { hint="Скоро · после внедрения биллинга" accent="rose" muted />
- + data.activeUsers + ? `+${data.totalUsers - data.activeUsers} деактивированных` + : 'все активны'} + accent="sky" />