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
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:
parent
ed24f5e354
commit
691448201d
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue