fix: пароль/orphan signup/tenant-guard toast/dashboard счётчик
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 48s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m9s
Docker Public / Build + push Public (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 31s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Public / Deploy Public on stage (push) Successful in 11s
Docker Web / Deploy Web on stage (push) Successful in 12s

- 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-юзеры уже деактивированы.
This commit is contained in:
nns 2026-04-27 18:38:56 +05:00
parent ed24f5e354
commit 691448201d
6 changed files with 72 additions and 31 deletions

View file

@ -42,7 +42,19 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
var existing = await _userMgr.FindByEmailAsync(input.Email); var existing = await _userMgr.FindByEmailAsync(input.Email);
if (existing is not null) 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-сущностей. // 1. Organization + полный bootstrap tenant-сущностей.
var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct); var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
@ -58,25 +70,50 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct); await DevDataSeeder.SeedTenantReferencesAsync(_db, org.Id, ct);
// 2. AppUser в роли Identity Admin, привязан к этой организации. // 2. AppUser — либо новый, либо реактивируем orphan (см. выше).
var user = new User User user;
if (existing is not null)
{ {
UserName = input.Email.Trim(), user = existing;
Email = input.Email.Trim(), user.OrganizationId = org.Id;
EmailConfirmed = true, user.IsActive = true;
FullName = input.OrganizationName.Trim(), user.FullName = input.OrganizationName.Trim();
OrganizationId = org.Id, user.Email = input.Email.Trim();
IsActive = true, user.EmailConfirmed = true;
}; await _userMgr.UpdateAsync(user);
var ur = await _userMgr.CreateAsync(user, input.Password); // Сброс пароля на тот, что юзер ввёл сейчас.
if (!ur.Succeeded) await _userMgr.RemovePasswordAsync(user);
{ var pwRes = await _userMgr.AddPasswordAsync(user, input.Password);
// Откат: убираем органзацию чтобы не оставить orphan. if (!pwRes.Succeeded)
_db.Organizations.Remove(org); {
await _db.SaveChangesAsync(ct); _db.Organizations.Remove(org);
return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) }); 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 с системной ролью «Администратор». // 3. Owner Employee с системной ролью «Администратор».
var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters() var adminRole = await _db.EmployeeRoles.IgnoreQueryFilters()

View file

@ -19,10 +19,13 @@ export function validateEmail(value: string): string | null {
} }
export function validatePassword(value: string): string | null { export function validatePassword(value: string): string | null {
// На клиенте — только базовая проверка длины. Все правила сложности
// (заглавные, цифры, спецсимволы) проверяет серверный ASP.NET Identity
// и возвращает конкретное сообщение в общий error-bar формы. Дублировать
// правила на фронте опасно: они расходятся с серверной политикой и
// блокируют ввод валидных паролей.
if (!value) return 'Это поле обязательно для заполнения' if (!value) return 'Это поле обязательно для заполнения'
if (value.length < 8) return 'Пароль должен быть не менее 8 символов' if (value.length < 8) return 'Пароль должен быть не менее 8 символов'
if (!/[A-ZА-Я]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву'
if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру'
return null return null
} }

View file

@ -100,12 +100,9 @@ export function AppLayout() {
// При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области // При активном «открыто как…» — слабая жёлтая тонировка фона tenant-области
// даёт периферийный сигнал «я не в своей админке». // даёт периферийный сигнал «я не в своей админке».
const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg') const inOverride = typeof window !== 'undefined' && !!localStorage.getItem('superAdminAsOrg')
// Toast на guard-message (TenantRouteGuard кладёт сообщение в sessionStorage // Tenant-guard tost удалён — раздражал юзера на каждом редиректе.
// перед редиректом — показываем разово). // Если когда-нибудь понадобится — лучше через ToastContainer, не alert().
useEffect(() => { useEffect(() => { sessionStorage.removeItem('tenant-guard-msg') }, [])
const msg = sessionStorage.getItem('tenant-guard-msg')
if (msg) { sessionStorage.removeItem('tenant-guard-msg'); alert(msg) }
}, [])
// Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем // Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем
// на setup wizard (нельзя пропустить, пока не создадут первую орг). // на setup wizard (нельзя пропустить, пока не создадут первую орг).
const location2 = useLocation() const location2 = useLocation()

View file

@ -30,7 +30,6 @@ export function TenantRouteGuard({ children }: { children: React.ReactNode }) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations', console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations',
{ hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') }) { hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') })
try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ }
navigate('/super-admin/organizations', { replace: true }) navigate('/super-admin/organizations', { replace: true })
} }
}) })

View file

@ -26,10 +26,12 @@ export function validateEmail(value: string): string | null {
} }
export function validatePassword(value: string): string | null { export function validatePassword(value: string): string | null {
// На клиенте — только проверка длины. Сложность (заглавные/цифры/
// спецсимволы) проверяет серверный Identity и возвращает конкретное
// сообщение в error-bar. Дубль правил на фронте расходится с серверной
// политикой и блокирует ввод валидных паролей.
if (!value) return messages.required if (!value) return messages.required
if (value.length < 8) return messages.passwordShort 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 return null
} }

View file

@ -125,8 +125,11 @@ export function SuperAdminDashboardPage() {
hint="Скоро · после внедрения биллинга" accent="rose" muted /> hint="Скоро · после внедрения биллинга" accent="rose" muted />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
<Kpi icon={Users} label="Пользователей" value={fmt.format(data?.totalUsers ?? 0)} <Kpi icon={Users} label="Пользователей" value={fmt.format(data?.activeUsers ?? 0)}
hint={`${data?.activeUsers ?? 0} активных`} accent="sky" /> hint={data && data.totalUsers > data.activeUsers
? `+${data.totalUsers - data.activeUsers} деактивированных`
: 'все активны'}
accent="sky" />
<Kpi icon={UserPlus} label="Регистраций за 30 дней" value={fmt.format(data?.registrationsLast30Days ?? 0)} <Kpi icon={UserPlus} label="Регистраций за 30 дней" value={fmt.format(data?.registrationsLast30Days ?? 0)}
hint="Новые организации" accent="amber" /> hint="Новые организации" accent="amber" />
</div> </div>