From 691448201de5db25c4e0763b5590ac40aaa62bce Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:38:56 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C/orp?= =?UTF-8?q?han=20signup/tenant-guard=20toast/dashboard=20=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D1=87=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validation: убрана клиентская проверка «должна быть заглавная/цифра» — она расходилась с серверной политикой Identity и блокировала валидные пароли. Серверный Identity сам валидирует и возвращает конкретное сообщение в общий error-bar формы. Клиент проверяет только длину 8. - /api/auth/signup: если AppUser с таким email уже есть, но он orphan (org удалена / архивирована / IsActive=false) — реактивируем его и привязываем к новой org вместо отказа «уже зарегистрирован». SuperAdmin не реактивируется (ему сценарий неактуален). Пароль перезаписывается на тот, что юзер ввёл сейчас. - TenantRouteGuard: убран alert «Откройте конкретную организацию через Открыть как…». На каждом редиректе SuperAdmin'а из tenant-роута всплывал window.alert — раздражал. Поведение редиректа сохранено. AppLayout чистит legacy ключ из sessionStorage. - SuperAdmin dashboard: KPI «Пользователей» теперь показывает количество активных, а в подсказке — сколько деактивированных. Раньше показывал total (включая orphan) — юзер видел «2», но в реальных списках их не было, потому что orphan-юзеры уже деактивированы. --- .../Controllers/AuthSignupController.cs | 73 ++++++++++++++----- src/food-market.public/src/lib/validation.ts | 7 +- .../src/components/AppLayout.tsx | 9 +-- .../src/components/TenantRouteGuard.tsx | 1 - src/food-market.web/src/lib/validation.ts | 6 +- .../src/pages/SuperAdminDashboardPage.tsx | 7 +- 6 files changed, 72 insertions(+), 31 deletions(-) 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" />