feat(api): super-admin endpoints (orgs CRUD + setup-status + audit-log + dashboard)
SuperAdminOrganizationsController (/api/super-admin/organizations):
все методы используют IgnoreQueryFilters() для обхода tenant-фильтра.
- GET / — таблица с пагинацией, фильтр archived, поиск по Name/Bin,
возвращает счётчики (employees, products) + last login по users.
- GET /{id} — детали + статистика (employees, products, counterparties,
supplies за 30 дней) + AccountOwner данные.
- POST / — создание орга вместе с админом: Org + Store «Основной» +
EmployeeRole «Администратор» (IsSystem) + AppUser (random temp pwd
возвращается один раз) + Employee. Owner = созданный AppUser.
- PUT /{id} — правка базовых данных, лог EditOrg с before/after.
- POST /{id}/archive — требует ConfirmationName == Org.Name (ввод).
- POST /{id}/restore — снять архив.
- DELETE /{id} — только если в архиве >30 дней + повторное подтверждение.
- POST /{id}/change-owner — Reason обязателен, валидируем что user
принадлежит этой орге, лог ChangeOwner с from/to.
Все мутации пишут запись в SuperAdminAuditLog с ActionType,
Description, Reason, ChangesJson, IpAddress, SuperAdminUserId.
SuperAdminController (/api/super-admin):
- GET /setup-status — нужен ли wizard? (OrgCount == 0).
- GET /dashboard — total/active/archived orgs, users, products, supplies/month.
- GET /audit-log — фильтры organizationId/actionType/from/to + paged + join
на orgs для имени.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e93634fad4
commit
18eb362702
|
|
@ -0,0 +1,81 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.SuperAdmin;
|
||||||
|
|
||||||
|
/// <summary>SuperAdmin: setup-status, dashboard статистика, audit log.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "SuperAdmin")]
|
||||||
|
[Route("api/super-admin")]
|
||||||
|
public class SuperAdminController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public SuperAdminController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record SetupStatusDto(bool NeedsSetup, int OrgCount);
|
||||||
|
|
||||||
|
[HttpGet("setup-status")]
|
||||||
|
public async Task<ActionResult<SetupStatusDto>> GetSetupStatus(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var count = await _db.Organizations.IgnoreQueryFilters().CountAsync(ct);
|
||||||
|
return new SetupStatusDto(NeedsSetup: count == 0, OrgCount: count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DashboardStats(
|
||||||
|
int TotalOrgs, int ActiveOrgs, int ArchivedOrgs,
|
||||||
|
int TotalUsers, int ActiveUsers,
|
||||||
|
int TotalProducts, int TotalSuppliesThisMonth);
|
||||||
|
|
||||||
|
[HttpGet("dashboard")]
|
||||||
|
public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var monthAgo = DateTime.UtcNow.AddDays(-30);
|
||||||
|
return new DashboardStats(
|
||||||
|
TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct),
|
||||||
|
ActiveOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => !o.IsArchived, ct),
|
||||||
|
ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct),
|
||||||
|
TotalUsers: await _db.Users.CountAsync(ct),
|
||||||
|
ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct),
|
||||||
|
TotalProducts: await _db.Products.IgnoreQueryFilters().CountAsync(ct),
|
||||||
|
TotalSuppliesThisMonth: await _db.Supplies.IgnoreQueryFilters().CountAsync(s => s.Date >= monthAgo, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AuditRow(
|
||||||
|
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
|
||||||
|
string ActionType, Guid? OrganizationId, string? OrganizationName,
|
||||||
|
string? EntityType, Guid? EntityId, string? Description, string? Reason, string IpAddress);
|
||||||
|
|
||||||
|
[HttpGet("audit-log")]
|
||||||
|
public async Task<ActionResult<PagedResult<AuditRow>>> AuditLog(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] Guid? organizationId,
|
||||||
|
[FromQuery] string? actionType,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.SuperAdminAuditLogs.AsNoTracking().AsQueryable();
|
||||||
|
if (organizationId is not null) q = q.Where(x => x.OrganizationId == organizationId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(actionType)) q = q.Where(x => x.ActionType == actionType);
|
||||||
|
if (from is not null) q = q.Where(x => x.CreatedAt >= from);
|
||||||
|
if (to is not null) q = q.Where(x => x.CreatedAt <= to);
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var orgNames = await _db.Organizations.IgnoreQueryFilters()
|
||||||
|
.ToDictionaryAsync(o => o.Id, o => o.Name, ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var rows = items.Select(x => new AuditRow(
|
||||||
|
x.Id, x.CreatedAt, x.SuperAdminUserId,
|
||||||
|
x.ActionType, x.OrganizationId,
|
||||||
|
x.OrganizationId is not null && orgNames.TryGetValue(x.OrganizationId.Value, out var n) ? n : null,
|
||||||
|
x.EntityType, x.EntityId, x.Description, x.Reason, x.IpAddress)).ToList();
|
||||||
|
return new PagedResult<AuditRow> { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
using foodmarket.Application.Common;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.SuperAdmin;
|
||||||
|
|
||||||
|
/// <summary>SuperAdmin console: управление организациями. Все запросы
|
||||||
|
/// IgnoreQueryFilters() — обходим tenant-фильтр, видим всё. Все мутации
|
||||||
|
/// логируются в super_admin_audit_log.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Roles = "SuperAdmin")]
|
||||||
|
[Route("api/super-admin/organizations")]
|
||||||
|
public class SuperAdminOrganizationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly UserManager<User> _userMgr;
|
||||||
|
|
||||||
|
public SuperAdminOrganizationsController(AppDbContext db, UserManager<User> userMgr)
|
||||||
|
{
|
||||||
|
_db = db; _userMgr = userMgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OrgRow(
|
||||||
|
Guid Id, string Name, string CountryCode,
|
||||||
|
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||||
|
DateTime CreatedAt, int EmployeeCount, int ProductCount,
|
||||||
|
DateTime? LastLoginAt);
|
||||||
|
|
||||||
|
public record OrgDetail(
|
||||||
|
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||||
|
Guid? DefaultCurrencyId, string? DefaultCurrencyCode,
|
||||||
|
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||||
|
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
|
||||||
|
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
|
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth);
|
||||||
|
|
||||||
|
public record OrgInput(
|
||||||
|
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||||
|
Guid? DefaultCurrencyId, Guid? AccountOwnerUserId);
|
||||||
|
|
||||||
|
public record CreateOrgRequest(OrgInput Org, string AdminLastName, string AdminFirstName,
|
||||||
|
string AdminEmail, string? AdminPosition);
|
||||||
|
|
||||||
|
public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string AdminTempPassword);
|
||||||
|
|
||||||
|
public record ArchiveRequest(string ConfirmationName);
|
||||||
|
public record DeleteRequest(string ConfirmationName);
|
||||||
|
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<OrgRow>>> List(
|
||||||
|
[FromQuery] PagedRequest req,
|
||||||
|
[FromQuery] bool? archived,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = _db.Organizations.IgnoreQueryFilters().AsNoTracking().AsQueryable();
|
||||||
|
if (archived is not null) q = q.Where(o => o.IsArchived == archived);
|
||||||
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
|
{
|
||||||
|
var s = req.Search.Trim().ToLower();
|
||||||
|
q = q.Where(o => o.Name.ToLower().Contains(s) || (o.Bin != null && o.Bin.Contains(s)));
|
||||||
|
}
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.OrderBy(o => o.IsArchived).ThenBy(o => o.Name)
|
||||||
|
.Skip(req.Skip).Take(req.Take)
|
||||||
|
.Select(o => new OrgRow(
|
||||||
|
o.Id, o.Name, o.CountryCode,
|
||||||
|
o.IsActive, o.IsArchived, o.ArchivedAt, o.CreatedAt,
|
||||||
|
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
|
||||||
|
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
|
||||||
|
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
|
||||||
|
.Max(u => (DateTime?)u.LastLoginAt)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await ProjectAsync(id, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var org = new Organization
|
||||||
|
{
|
||||||
|
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
|
||||||
|
Bin = input.Org.Bin, Address = input.Org.Address,
|
||||||
|
Phone = input.Org.Phone, Email = input.Org.Email,
|
||||||
|
DefaultCurrencyId = input.Org.DefaultCurrencyId,
|
||||||
|
};
|
||||||
|
_db.Organizations.Add(org);
|
||||||
|
// Системные референсы (Stores, Cashiers, Roles) подсеет DevDataSeeder при
|
||||||
|
// следующем рестарте; для немедленной готовности создаём минимум руками.
|
||||||
|
var mainStore = new Store
|
||||||
|
{
|
||||||
|
OrganizationId = org.Id, Name = "Основной склад", Code = "MAIN", IsMain = true,
|
||||||
|
};
|
||||||
|
_db.Stores.Add(mainStore);
|
||||||
|
|
||||||
|
var adminRole = new EmployeeRole
|
||||||
|
{
|
||||||
|
OrganizationId = org.Id,
|
||||||
|
Name = "Администратор", Description = "Полный доступ ко всем разделам организации",
|
||||||
|
IsSystem = true, SortOrder = 0, Permissions = RolePermissions.All(),
|
||||||
|
};
|
||||||
|
_db.EmployeeRoles.Add(adminRole);
|
||||||
|
|
||||||
|
// AppUser админа
|
||||||
|
var existing = await _userMgr.FindByEmailAsync(input.AdminEmail);
|
||||||
|
if (existing is not null)
|
||||||
|
return BadRequest(new { error = $"Пользователь {input.AdminEmail} уже существует." });
|
||||||
|
var tempPwd = GenerateTempPassword();
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
UserName = input.AdminEmail, Email = input.AdminEmail, EmailConfirmed = true,
|
||||||
|
FullName = $"{input.AdminLastName} {input.AdminFirstName}".Trim(),
|
||||||
|
OrganizationId = org.Id, IsActive = true,
|
||||||
|
};
|
||||||
|
var ur = await _userMgr.CreateAsync(user, tempPwd);
|
||||||
|
if (!ur.Succeeded) return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
|
||||||
|
await _userMgr.AddToRoleAsync(user, "Admin");
|
||||||
|
org.AccountOwnerUserId = user.Id;
|
||||||
|
|
||||||
|
_db.Employees.Add(new Employee
|
||||||
|
{
|
||||||
|
OrganizationId = org.Id, UserId = user.Id,
|
||||||
|
LastName = input.AdminLastName, FirstName = input.AdminFirstName,
|
||||||
|
Position = input.AdminPosition ?? "Директор",
|
||||||
|
Email = input.AdminEmail, Role = adminRole, IsActive = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("CreateOrg", org.Id, $"Создана организация «{org.Name}»", null, $"{{\"adminEmail\":\"{input.AdminEmail}\"}}", ct);
|
||||||
|
|
||||||
|
var detail = await ProjectAsync(org.Id, ct);
|
||||||
|
return new CreateOrgResult(detail!, input.AdminEmail, tempPwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] OrgInput input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
|
o.Name = input.Name; o.CountryCode = input.CountryCode;
|
||||||
|
o.Bin = input.Bin; o.Address = input.Address;
|
||||||
|
o.Phone = input.Phone; o.Email = input.Email;
|
||||||
|
o.DefaultCurrencyId = input.DefaultCurrencyId;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
|
await LogAsync("EditOrg", o.Id, $"Изменены данные «{o.Name}»", null,
|
||||||
|
$"{{\"before\":{before},\"after\":{after}}}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/archive")]
|
||||||
|
public async Task<IActionResult> Archive(Guid id, [FromBody] ArchiveRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (o.IsArchived) return Conflict(new { error = "Уже в архиве." });
|
||||||
|
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно для подтверждения." });
|
||||||
|
o.IsArchived = true; o.ArchivedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("ArchiveOrg", o.Id, $"Архивирована «{o.Name}»", null, "{}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/restore")]
|
||||||
|
public async Task<IActionResult> Restore(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (!o.IsArchived) return Conflict(new { error = "Не в архиве." });
|
||||||
|
o.IsArchived = false; o.ArchivedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("RestoreOrg", o.Id, $"Восстановлена из архива «{o.Name}»", null, "{}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (!o.IsArchived || o.ArchivedAt is null || o.ArchivedAt > DateTime.UtcNow.AddDays(-30))
|
||||||
|
return Conflict(new { error = "Удалить можно только организации в архиве >30 дней." });
|
||||||
|
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
|
||||||
|
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
|
||||||
|
$"{{\"name\":\"{o.Name}\"}}", ct);
|
||||||
|
// Cascade delete domain entities is up to FK config; здесь просто Remove,
|
||||||
|
// EF выкинет ошибку если есть restrict-связи — оператор увидит и решит.
|
||||||
|
_db.Organizations.Remove(o);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/change-owner")]
|
||||||
|
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." });
|
||||||
|
var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString());
|
||||||
|
if (user is null || user.OrganizationId != o.Id)
|
||||||
|
return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." });
|
||||||
|
var prev = o.AccountOwnerUserId;
|
||||||
|
o.AccountOwnerUserId = req.NewOwnerUserId;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
await LogAsync("ChangeOwner", o.Id, $"Сменён владелец «{o.Name}»", req.Reason,
|
||||||
|
$"{{\"from\":\"{prev}\",\"to\":\"{req.NewOwnerUserId}\"}}", ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OrgDetail?> ProjectAsync(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().AsNoTracking()
|
||||||
|
.Include(x => x.DefaultCurrency)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return null;
|
||||||
|
var emp = await _db.Employees.IgnoreQueryFilters().CountAsync(e => e.OrganizationId == id, ct);
|
||||||
|
var prod = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == id, ct);
|
||||||
|
var cp = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == id, ct);
|
||||||
|
var monthAgo = DateTime.UtcNow.AddDays(-30);
|
||||||
|
var supplies = await _db.Supplies.IgnoreQueryFilters()
|
||||||
|
.CountAsync(s => s.OrganizationId == id && s.Date >= monthAgo, ct);
|
||||||
|
User? owner = null;
|
||||||
|
if (o.AccountOwnerUserId is not null)
|
||||||
|
owner = await _userMgr.FindByIdAsync(o.AccountOwnerUserId.ToString()!);
|
||||||
|
return new OrgDetail(
|
||||||
|
o.Id, o.Name, o.CountryCode, o.Bin, o.Address, o.Phone, o.Email,
|
||||||
|
o.DefaultCurrencyId, o.DefaultCurrency?.Code,
|
||||||
|
o.IsActive, o.IsArchived, o.ArchivedAt,
|
||||||
|
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
|
||||||
|
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||||
|
Guid.TryParse(userIdRaw, out var uid);
|
||||||
|
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
|
||||||
|
{
|
||||||
|
SuperAdminUserId = uid,
|
||||||
|
ActionType = actionType, OrganizationId = orgId,
|
||||||
|
Description = description, Reason = reason,
|
||||||
|
ChangesJson = changesJson,
|
||||||
|
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateTempPassword()
|
||||||
|
{
|
||||||
|
const string lower = "abcdefghkmnpqrstuvwxyz";
|
||||||
|
const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
|
||||||
|
const string digits = "23456789";
|
||||||
|
const string special = "!@#$%&*";
|
||||||
|
var rnd = new Random();
|
||||||
|
var chars = new List<char>
|
||||||
|
{
|
||||||
|
upper[rnd.Next(upper.Length)],
|
||||||
|
lower[rnd.Next(lower.Length)],
|
||||||
|
digits[rnd.Next(digits.Length)],
|
||||||
|
special[rnd.Next(special.Length)],
|
||||||
|
};
|
||||||
|
var pool = lower + upper + digits;
|
||||||
|
for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
|
||||||
|
return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue