< Summary

Information
Class: OzonOrdersWeb.Areas.Admin.Controllers.UsersController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Admin/Controllers/UsersController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 444
Coverable lines: 444
Total lines: 663
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 128
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Index()100%210%
Create()100%210%
Create()0%702260%
BuildCreateVmAsync()100%210%
BuildUserListAsync()0%620%
Lock()0%620%
Unlock()0%620%
Edit()0%2040%
ChangeEmail()0%156120%
SendEmailConfirmation()0%4260%
ApproveSignIn()0%4260%
RevokeSignInApproval()0%2040%
ChangePassword()0%7280%
BuildEditVmAsync()0%2040%
SendConfirmationEmailAsync()0%620%
SaveRoles()0%110100%
SavePageOverride()0%272160%
RemovePageOverride()0%620%
SaveFieldOverride()0%110100%
RemoveFieldOverride()0%620%
SaveDataScopes()0%110100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Admin/Controllers/UsersController.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Identity;
 2using Microsoft.AspNetCore.Mvc;
 3using Microsoft.AspNetCore.WebUtilities;
 4using Microsoft.EntityFrameworkCore;
 5using Microsoft.Extensions.Options;
 6using OzonDomains.Models.Authorization;
 7using OzonOrdersWeb.Areas.Admin.Models;
 8using OzonRepositories.Context;
 9using OzonRepositories.Context.Identity;
 10using Services.Authorization;
 11using Services.Authorization.Services;
 12using SlqStudio.Application.Services.EmailService.Models;
 13using System.Net;
 14using System.Net.Mail;
 15using System.Text;
 16
 17namespace OzonOrdersWeb.Areas.Admin.Controllers;
 18
 19/// <summary>
 20/// Управление пользователями приложения: назначение ролей и персональных override на страницы/поля.
 21/// </summary>
 22public class UsersController : AdminAreaControllerBase
 23{
 24    private readonly ApplicationDbContext _db;
 25    private readonly UserManager<CustomIdentityUser> _userManager;
 26    private readonly IAuditService _audit;
 27    private readonly IPermissionService _permissions;
 28    private readonly SmtpSettings _smtpSettings;
 29
 030    public UsersController(
 031        ApplicationDbContext db,
 032        UserManager<CustomIdentityUser> userManager,
 033        IAuditService audit,
 034        IPermissionService permissions,
 035        IOptions<SmtpSettings> smtpSettings)
 36    {
 037        _db = db;
 038        _userManager = userManager;
 039        _audit = audit;
 040        _permissions = permissions;
 041        _smtpSettings = smtpSettings.Value;
 042    }
 43
 44    public async Task<IActionResult> Index(string? q, CancellationToken ct)
 45    {
 046        ViewData["Title"] = "Пользователи";
 047        ViewData["Breadcrumb"] = new List<(string, string?)> { ("Админка", Url.Action("Index", "Home")), ("Пользователи"
 048        return View(new UsersIndexVm
 049        {
 050            Query = q,
 051            Items = await BuildUserListAsync(q, ct),
 052        });
 053    }
 54
 55    [HttpGet]
 56    public async Task<IActionResult> Create(string? q, CancellationToken ct)
 57    {
 058        ViewData["Title"] = "Создание пользователя";
 059        ViewData["Breadcrumb"] = new List<(string, string?)>
 060        {
 061            ("Админка", Url.Action("Index", "Home")),
 062            ("Пользователи", Url.Action("Index")),
 063            ("Новый пользователь", null),
 064        };
 65
 066        return View(await BuildCreateVmAsync(q, new UserCreateVm(), ct));
 067    }
 68
 69    [HttpPost]
 70    [ValidateAntiForgeryToken]
 71    public async Task<IActionResult> Create([Bind(Prefix = "Create")] UserCreateVm model, int[]? roleIds, string? q, Can
 72    {
 073        ViewData["Title"] = "Создание пользователя";
 074        ViewData["Breadcrumb"] = new List<(string, string?)>
 075        {
 076            ("Админка", Url.Action("Index", "Home")),
 077            ("Пользователи", Url.Action("Index")),
 078            ("Новый пользователь", null),
 079        };
 80
 081        model.UserName = model.UserName?.Trim() ?? string.Empty;
 082        model.Email = model.Email?.Trim() ?? string.Empty;
 83
 084        if (await _userManager.FindByNameAsync(model.UserName) != null)
 85        {
 086            ModelState.AddModelError(nameof(model.UserName), "Пользователь с таким логином уже существует.");
 87        }
 088        if (await _userManager.FindByEmailAsync(model.Email) != null)
 89        {
 090            ModelState.AddModelError(nameof(model.Email), "Пользователь с таким email уже существует.");
 91        }
 92
 093        var availableRoles = await _db.AppRoles.Where(r => r.IsActive).OrderBy(r => r.DisplayName).ToListAsync(ct);
 094        model.SelectedRoleIds = (roleIds ?? Array.Empty<int>()).ToHashSet();
 95
 096        if (!ModelState.IsValid)
 97        {
 098            return View(await BuildCreateVmAsync(q, model, ct));
 99        }
 100
 0101        var user = new CustomIdentityUser
 0102        {
 0103            UserName = model.UserName,
 0104            Email = model.Email,
 0105            EmailConfirmed = true,
 0106            IsAdminApproved = true,
 0107            AdminApprovedAtUtc = DateTime.UtcNow,
 0108            AdminApprovedBy = User.Identity?.Name
 0109        };
 0110        var createResult = await _userManager.CreateAsync(user, model.Password);
 0111        if (!createResult.Succeeded)
 112        {
 0113            foreach (var error in createResult.Errors)
 114            {
 0115                ModelState.AddModelError(string.Empty, error.Description);
 116            }
 0117            return View(await BuildCreateVmAsync(q, model, ct));
 118        }
 119
 0120        var selectedRoleIds = model.SelectedRoleIds.Where(id => availableRoles.Any(r => r.Id == id)).ToList();
 0121        foreach (var rid in selectedRoleIds)
 122        {
 0123            _db.UserAppRoles.Add(new UserAppRole
 0124            {
 0125                UserId = user.Id,
 0126                AppRoleId = rid,
 0127                AssignedAt = DateTime.UtcNow,
 0128                AssignedBy = User.Identity?.Name,
 0129            });
 130        }
 0131        await _db.SaveChangesAsync(ct);
 0132        _permissions.InvalidateUserCache(user.Id);
 0133        TempData["SuccessMessage"] = "Пользователь успешно создан.";
 0134        return RedirectToAction(nameof(Index), new { q });
 0135    }
 136
 137    private async Task<UserCreatePageVm> BuildCreateVmAsync(string? q, UserCreateVm createVm, CancellationToken ct)
 138    {
 0139        var availableRoles = await _db.AppRoles
 0140            .Where(r => r.IsActive)
 0141            .OrderBy(r => r.DisplayName)
 0142            .Select(r => new RoleListItemVm
 0143            {
 0144                Id = r.Id,
 0145                Name = r.Name,
 0146                DisplayName = r.DisplayName,
 0147                Description = r.Description,
 0148                IsSystem = r.IsSystem,
 0149                IsActive = r.IsActive,
 0150            })
 0151            .ToListAsync(ct);
 152
 0153        return new UserCreatePageVm
 0154        {
 0155            Query = q,
 0156            Create = createVm,
 0157            AvailableRoles = availableRoles,
 0158        };
 0159    }
 160
 161    private async Task<List<UserListItemVm>> BuildUserListAsync(string? q, CancellationToken ct)
 162    {
 0163        var users = _userManager.Users.AsQueryable();
 0164        if (!string.IsNullOrWhiteSpace(q))
 165        {
 0166            users = users.Where(u => u.UserName!.Contains(q) || u.Email!.Contains(q));
 167        }
 0168        var list = await users.OrderBy(u => u.UserName).ToListAsync(ct);
 0169        var userIds = list.Select(u => u.Id).ToList();
 170
 0171        var userRoles = await _db.UserAppRoles
 0172            .Where(ur => userIds.Contains(ur.UserId))
 0173            .Select(ur => new { ur.UserId, ur.AppRole.DisplayName })
 0174            .ToListAsync(ct);
 0175        var overrideCounts = await _db.UserPageOverrides
 0176            .Where(o => userIds.Contains(o.UserId))
 0177            .GroupBy(o => o.UserId)
 0178            .Select(g => new { UserId = g.Key, Count = g.Count() })
 0179            .ToListAsync(ct);
 180
 0181        return list.Select(u => new UserListItemVm
 0182        {
 0183            Id = u.Id,
 0184            UserName = u.UserName ?? string.Empty,
 0185            Email = u.Email,
 0186            GoogleDisplayName = u.GoogleDisplayName,
 0187            GoogleAvatarUrl = u.GoogleAvatarUrl,
 0188            EmailConfirmed = u.EmailConfirmed,
 0189            IsAdminApproved = u.IsAdminApproved,
 0190            RoleNames = userRoles.Where(r => r.UserId == u.Id).Select(r => r.DisplayName).ToList(),
 0191            OverrideCount = overrideCounts.FirstOrDefault(o => o.UserId == u.Id)?.Count ?? 0,
 0192            IsLocked = u.LockoutEnd.HasValue && u.LockoutEnd > DateTimeOffset.UtcNow,
 0193            AccessFailedCount = u.AccessFailedCount,
 0194        }).ToList();
 0195    }
 196
 197    [HttpPost]
 198    [ValidateAntiForgeryToken]
 199    public async Task<IActionResult> Lock(string id, CancellationToken ct)
 200    {
 0201        var user = await _userManager.FindByIdAsync(id);
 0202        if (user == null) return NotFound();
 0203        await _userManager.SetLockoutEnabledAsync(user, true);
 0204        var until = DateTimeOffset.UtcNow.AddYears(25);
 0205        await _userManager.SetLockoutEndDateAsync(user, until);
 0206        await _audit.LogAsync(AuthorizationConstants.AuditActionUserOverrideChanged, nameof(CustomIdentityUser), id, nul
 0207        _permissions.InvalidateUserCache(id);
 0208        TempData["SuccessMessage"] = "Пользователь заблокирован.";
 0209        return RedirectToAction(nameof(Index));
 0210    }
 211
 212    [HttpPost]
 213    [ValidateAntiForgeryToken]
 214    public async Task<IActionResult> Unlock(string id, CancellationToken ct)
 215    {
 0216        var user = await _userManager.FindByIdAsync(id);
 0217        if (user == null) return NotFound();
 0218        await _userManager.SetLockoutEndDateAsync(user, null);
 0219        await _userManager.ResetAccessFailedCountAsync(user);
 0220        await _audit.LogAsync(AuthorizationConstants.AuditActionUserOverrideChanged, nameof(CustomIdentityUser), id, nul
 0221        _permissions.InvalidateUserCache(id);
 0222        TempData["SuccessMessage"] = "Пользователь разблокирован, счётчик неудачных попыток входа сброшен.";
 0223        return RedirectToAction(nameof(Index));
 0224    }
 225
 226    [HttpGet]
 227    public async Task<IActionResult> Edit(string id, CancellationToken ct)
 228    {
 0229        var user = await _userManager.FindByIdAsync(id);
 0230        if (user == null) return NotFound();
 231
 0232        ViewData["Title"] = $"Пользователь: {user.UserName}";
 0233        ViewData["Breadcrumb"] = new List<(string, string?)>
 0234        {
 0235            ("Админка", Url.Action("Index", "Home")),
 0236            ("Пользователи", Url.Action("Index")),
 0237            (user.UserName ?? id, null),
 0238        };
 239
 0240        return View(await BuildEditVmAsync(user, ct));
 0241    }
 242
 243    [HttpPost]
 244    [ValidateAntiForgeryToken]
 245    public async Task<IActionResult> ChangeEmail(string id, [Bind(Prefix = "EmailChange")] UserEmailChangeVm emailChange
 246    {
 0247        var user = await _userManager.FindByIdAsync(id);
 0248        if (user == null) return NotFound();
 249
 0250        if (!ModelState.IsValid)
 251        {
 0252            var invalidVm = await BuildEditVmAsync(user, ct);
 0253            invalidVm.EmailChange = emailChange;
 0254            return View(nameof(Edit), invalidVm);
 255        }
 256
 0257        var normalizedEmail = emailChange.NewEmail.Trim();
 0258        var existingByEmail = await _userManager.FindByEmailAsync(normalizedEmail);
 0259        if (existingByEmail != null && existingByEmail.Id != user.Id)
 260        {
 0261            ModelState.AddModelError("EmailChange.NewEmail", "Пользователь с таким email уже существует.");
 0262            var invalidVm = await BuildEditVmAsync(user, ct);
 0263            invalidVm.EmailChange = emailChange;
 0264            return View(nameof(Edit), invalidVm);
 265        }
 266
 0267        var oldEmail = user.Email;
 0268        var oldUserName = user.UserName;
 0269        user.Email = normalizedEmail;
 0270        user.NormalizedEmail = _userManager.NormalizeEmail(normalizedEmail);
 0271        user.UserName = normalizedEmail;
 0272        user.NormalizedUserName = _userManager.NormalizeName(normalizedEmail);
 0273        user.EmailConfirmed = false;
 0274        user.IsAdminApproved = false;
 0275        user.AdminApprovedAtUtc = null;
 0276        user.AdminApprovedBy = null;
 277
 0278        var updateResult = await _userManager.UpdateAsync(user);
 0279        if (!updateResult.Succeeded)
 280        {
 0281            foreach (var error in updateResult.Errors)
 282            {
 0283                ModelState.AddModelError(string.Empty, error.Description);
 284            }
 285
 0286            var invalidVm = await BuildEditVmAsync(user, ct);
 0287            invalidVm.EmailChange = emailChange;
 0288            return View(nameof(Edit), invalidVm);
 289        }
 290
 0291        await _audit.LogAsync(
 0292            AuthorizationConstants.AuditActionUserOverrideChanged,
 0293            nameof(CustomIdentityUser),
 0294            id,
 0295            $"{oldEmail} | {oldUserName}",
 0296            $"{normalizedEmail} | {normalizedEmail}",
 0297            "Смена email и логина пользователя администратором (сброшены подтверждение email и admin-approval)",
 0298            ct);
 0299        _permissions.InvalidateUserCache(id);
 300
 0301        TempData["SuccessMessage"] = "Email изменен. Требуется подтверждение email и повторное одобрение администратором
 0302        return RedirectToAction(nameof(Edit), new { id });
 0303    }
 304
 305    [HttpPost]
 306    [ValidateAntiForgeryToken]
 307    public async Task<IActionResult> SendEmailConfirmation(string id, CancellationToken ct)
 308    {
 0309        var user = await _userManager.FindByIdAsync(id);
 0310        if (user == null) return NotFound();
 0311        if (string.IsNullOrWhiteSpace(user.Email))
 312        {
 0313            TempData["ErrorMessage"] = "У пользователя не задан email.";
 0314            return RedirectToAction(nameof(Edit), new { id });
 315        }
 316
 0317        var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
 0318        code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
 0319        var callbackUrl = Url.Page(
 0320            "/Account/ConfirmEmail",
 0321            pageHandler: null,
 0322            values: new { area = "Identity", userId = user.Id, code, returnUrl = "/Studio2/OrdersGrid" },
 0323            protocol: Request.Scheme);
 324
 0325        if (string.IsNullOrWhiteSpace(callbackUrl))
 326        {
 0327            TempData["ErrorMessage"] = "Не удалось сформировать ссылку подтверждения.";
 0328            return RedirectToAction(nameof(Edit), new { id });
 329        }
 330
 0331        var subject = "Подтверждение email для входа в Studio2";
 0332        var body = $"""
 0333                    <p>Здравствуйте.</p>
 0334                    <p>Для подтверждения email перейдите по ссылке:</p>
 0335                    <p><a href="{callbackUrl}">Подтвердить email</a></p>
 0336                    <p>После подтверждения email вход будет доступен только после одобрения администратором.</p>
 0337                    """;
 0338        await SendConfirmationEmailAsync(user.Email, subject, body);
 339
 0340        await _audit.LogAsync(
 0341            AuthorizationConstants.AuditActionUserOverrideChanged,
 0342            nameof(CustomIdentityUser),
 0343            id,
 0344            null,
 0345            "email_confirmation_sent",
 0346            "Отправлено письмо подтверждения email администратором",
 0347            ct);
 348
 0349        TempData["SuccessMessage"] = "Письмо подтверждения отправлено пользователю.";
 0350        return RedirectToAction(nameof(Edit), new { id });
 0351    }
 352
 353    [HttpPost]
 354    [ValidateAntiForgeryToken]
 355    public async Task<IActionResult> ApproveSignIn(string id, CancellationToken ct)
 356    {
 0357        var user = await _userManager.FindByIdAsync(id);
 0358        if (user == null) return NotFound();
 359
 0360        var oldState = $"EmailConfirmed={user.EmailConfirmed}; IsAdminApproved={user.IsAdminApproved}";
 0361        user.EmailConfirmed = true;
 0362        user.IsAdminApproved = true;
 0363        user.AdminApprovedAtUtc = DateTime.UtcNow;
 0364        user.AdminApprovedBy = User.Identity?.Name;
 0365        var updateResult = await _userManager.UpdateAsync(user);
 0366        if (!updateResult.Succeeded)
 367        {
 0368            TempData["ErrorMessage"] = string.Join("; ", updateResult.Errors.Select(e => e.Description));
 0369            return RedirectToAction(nameof(Edit), new { id });
 370        }
 371
 0372        await _audit.LogAsync(
 0373            AuthorizationConstants.AuditActionUserOverrideChanged,
 0374            nameof(CustomIdentityUser),
 0375            id,
 0376            oldState,
 0377            "EmailConfirmed=true; IsAdminApproved=true",
 0378            "Пользователь одобрен администратором для входа (включая подтверждение email)",
 0379            ct);
 0380        _permissions.InvalidateUserCache(id);
 381
 0382        TempData["SuccessMessage"] = "Доступ пользователя одобрен администратором. Email подтвержден.";
 0383        return RedirectToAction(nameof(Edit), new { id });
 0384    }
 385
 386    [HttpPost]
 387    [ValidateAntiForgeryToken]
 388    public async Task<IActionResult> RevokeSignInApproval(string id, CancellationToken ct)
 389    {
 0390        var user = await _userManager.FindByIdAsync(id);
 0391        if (user == null) return NotFound();
 392
 0393        user.IsAdminApproved = false;
 0394        user.AdminApprovedAtUtc = null;
 0395        user.AdminApprovedBy = null;
 0396        var updateResult = await _userManager.UpdateAsync(user);
 0397        if (!updateResult.Succeeded)
 398        {
 0399            TempData["ErrorMessage"] = string.Join("; ", updateResult.Errors.Select(e => e.Description));
 0400            return RedirectToAction(nameof(Edit), new { id });
 401        }
 402
 0403        await _audit.LogAsync(
 0404            AuthorizationConstants.AuditActionUserOverrideChanged,
 0405            nameof(CustomIdentityUser),
 0406            id,
 0407            null,
 0408            "admin_approval_revoked",
 0409            "Администратор снял одобрение входа",
 0410            ct);
 0411        _permissions.InvalidateUserCache(id);
 412
 0413        TempData["SuccessMessage"] = "Одобрение входа снято.";
 0414        return RedirectToAction(nameof(Edit), new { id });
 0415    }
 416
 417    [HttpPost]
 418    [ValidateAntiForgeryToken]
 419    public async Task<IActionResult> ChangePassword(string id, [Bind(Prefix = "PasswordChange")] UserPasswordChangeVm pa
 420    {
 0421        var user = await _userManager.FindByIdAsync(id);
 0422        if (user == null) return NotFound();
 423
 0424        if (!ModelState.IsValid)
 425        {
 0426            var invalidVm = await BuildEditVmAsync(user, ct);
 0427            invalidVm.PasswordChange = passwordChange;
 0428            return View(nameof(Edit), invalidVm);
 429        }
 430
 0431        var token = await _userManager.GeneratePasswordResetTokenAsync(user);
 0432        var resetResult = await _userManager.ResetPasswordAsync(user, token, passwordChange.NewPassword);
 0433        if (!resetResult.Succeeded)
 434        {
 0435            foreach (var error in resetResult.Errors)
 436            {
 0437                ModelState.AddModelError(string.Empty, error.Description);
 438            }
 439
 0440            var invalidVm = await BuildEditVmAsync(user, ct);
 0441            invalidVm.PasswordChange = passwordChange;
 0442            return View(nameof(Edit), invalidVm);
 443        }
 444
 0445        await _audit.LogAsync(
 0446            AuthorizationConstants.AuditActionUserOverrideChanged,
 0447            nameof(CustomIdentityUser),
 0448            id,
 0449            null,
 0450            "password_changed",
 0451            "Смена пароля пользователя администратором",
 0452            ct: ct);
 0453        _permissions.InvalidateUserCache(id);
 454
 0455        TempData["SuccessMessage"] = "Пароль пользователя обновлён.";
 0456        return RedirectToAction(nameof(Edit), new { id });
 0457    }
 458
 459    private async Task<UserEditVm> BuildEditVmAsync(CustomIdentityUser user, CancellationToken ct)
 460    {
 0461        var id = user.Id;
 0462        var allRoles = await _db.AppRoles.OrderBy(r => r.DisplayName).ToListAsync(ct);
 0463        var selectedRoleIds = await _db.UserAppRoles.Where(ur => ur.UserId == id).Select(ur => ur.AppRoleId).ToListAsync
 0464        var pageOverrides = await _db.UserPageOverrides.Where(o => o.UserId == id).Include(o => o.AppPage).ToListAsync(c
 0465        var fieldOverrides = await _db.UserFieldOverrides.Where(o => o.UserId == id).Include(o => o.AppField).ToListAsyn
 466
 0467        var allScopes = await _db.DataScopes.AsNoTracking().OrderBy(s => s.EntityName).ThenBy(s => s.DisplayName).ToList
 0468        var userScopeIds = await _db.UserDataScopes.Where(o => o.UserId == id).Select(o => o.DataScopeId).ToListAsync(ct
 0469        var roleScopeIds = await _db.RoleDataScopes
 0470            .Where(r => selectedRoleIds.Contains(r.AppRoleId))
 0471            .Select(r => r.DataScopeId)
 0472            .Distinct()
 0473            .ToListAsync(ct);
 474
 0475        return new UserEditVm
 0476        {
 0477            Id = id,
 0478            UserName = user.UserName ?? string.Empty,
 0479            Email = user.Email,
 0480            EmailConfirmed = user.EmailConfirmed,
 0481            IsAdminApproved = user.IsAdminApproved,
 0482            AdminApprovedAtUtc = user.AdminApprovedAtUtc,
 0483            AdminApprovedBy = user.AdminApprovedBy,
 0484            EmailChange = new UserEmailChangeVm { NewEmail = user.Email ?? string.Empty },
 0485            AllRoles = allRoles.Select(r => new RoleListItemVm
 0486            {
 0487                Id = r.Id, Name = r.Name, DisplayName = r.DisplayName, Description = r.Description, IsSystem = r.IsSyste
 0488            }).ToList(),
 0489            SelectedRoleIds = selectedRoleIds.ToHashSet(),
 0490            PageOverrides = pageOverrides.Select(o => new UserPageOverrideRow
 0491            {
 0492                PageId = o.AppPageId, Code = o.AppPage.Code, DisplayName = o.AppPage.DisplayName,
 0493                CanView = o.CanView, CanCreate = o.CanCreate, CanEdit = o.CanEdit, CanDelete = o.CanDelete, CanExport = 
 0494            }).ToList(),
 0495            FieldOverrides = fieldOverrides.Select(o => new UserFieldOverrideRow
 0496            {
 0497                FieldId = o.AppFieldId, EntityName = o.AppField.EntityName, FieldName = o.AppField.FieldName,
 0498                DisplayName = o.AppField.DisplayName, CanRead = o.CanRead, CanWrite = o.CanWrite,
 0499            }).ToList(),
 0500            AllDataScopes = allScopes.Select(s => new UserDataScopeOption
 0501            {
 0502                Id = s.Id, Code = s.Code, DisplayName = s.DisplayName, EntityName = s.EntityName,
 0503                FilterExpression = s.FilterExpression, Description = s.Description, IsActive = s.IsActive,
 0504                FromRole = roleScopeIds.Contains(s.Id),
 0505            }).ToList(),
 0506            SelectedDataScopeIds = userScopeIds.ToHashSet(),
 0507            PasswordChange = new UserPasswordChangeVm(),
 0508        };
 0509    }
 510
 511    private async Task SendConfirmationEmailAsync(string toAddress, string subject, string htmlBody)
 512    {
 0513        var fromAddress = new MailAddress(_smtpSettings.SenderEmail ?? string.Empty, _smtpSettings.SenderName);
 0514        var to = new MailAddress(toAddress);
 0515        using var message = new MailMessage(fromAddress, to)
 0516        {
 0517            Subject = subject,
 0518            Body = htmlBody,
 0519            IsBodyHtml = true
 0520        };
 521
 0522        using var smtp = new SmtpClient(_smtpSettings.Server, _smtpSettings.Port)
 0523        {
 0524            Credentials = new NetworkCredential(_smtpSettings.SenderEmail, _smtpSettings.Password),
 0525            EnableSsl = _smtpSettings.EnableSsl
 0526        };
 527
 0528        await smtp.SendMailAsync(message);
 0529    }
 530
 531    [HttpPost]
 532    [ValidateAntiForgeryToken]
 533    public async Task<IActionResult> SaveRoles(string id, int[] roleIds, CancellationToken ct)
 534    {
 0535        var user = await _userManager.FindByIdAsync(id);
 0536        if (user == null) return NotFound();
 537
 0538        var current = await _db.UserAppRoles.Where(ur => ur.UserId == id).ToListAsync(ct);
 0539        var oldIds = current.Select(c => c.AppRoleId).ToList();
 0540        var newIds = (roleIds ?? Array.Empty<int>()).Distinct().ToList();
 541
 0542        _db.RemoveRange(current.Where(c => !newIds.Contains(c.AppRoleId)));
 0543        foreach (var rid in newIds.Where(r => !oldIds.Contains(r)))
 544        {
 0545            _db.UserAppRoles.Add(new UserAppRole
 0546            {
 0547                UserId = id, AppRoleId = rid,
 0548                AssignedAt = DateTime.UtcNow,
 0549                AssignedBy = User.Identity?.Name,
 0550            });
 551        }
 0552        await _db.SaveChangesAsync(ct);
 0553        await _audit.LogAsync(AuthorizationConstants.AuditActionUserRoleAssigned, nameof(UserAppRole), id,
 0554            System.Text.Json.JsonSerializer.Serialize(oldIds),
 0555            System.Text.Json.JsonSerializer.Serialize(newIds), ct: ct);
 0556        _permissions.InvalidateUserCache(id);
 557
 0558        TempData["SuccessMessage"] = "Роли пользователя обновлены";
 0559        return RedirectToAction(nameof(Edit), new { id });
 0560    }
 561
 562    [HttpPost]
 563    [ValidateAntiForgeryToken]
 564    public async Task<IActionResult> SavePageOverride(string id, int pageId, bool? canView, bool? canCreate, bool? canEd
 565    {
 0566        var user = await _userManager.FindByIdAsync(id);
 0567        if (user == null) return NotFound();
 568
 0569        var o = await _db.UserPageOverrides.FirstOrDefaultAsync(x => x.UserId == id && x.AppPageId == pageId, ct);
 0570        var hasAny = canView.HasValue || canCreate.HasValue || canEdit.HasValue || canDelete.HasValue || canExport.HasVa
 0571        if (o == null)
 572        {
 0573            if (!hasAny) return Ok();
 0574            o = new UserPageOverride { UserId = id, AppPageId = pageId };
 0575            _db.UserPageOverrides.Add(o);
 576        }
 0577        o.CanView = canView; o.CanCreate = canCreate; o.CanEdit = canEdit; o.CanDelete = canDelete; o.CanExport = canExp
 0578        if (!hasAny) _db.UserPageOverrides.Remove(o);
 579
 0580        await _db.SaveChangesAsync(ct);
 0581        await _audit.LogAsync(AuthorizationConstants.AuditActionUserOverrideChanged, nameof(UserPageOverride), $"user={i
 0582        _permissions.InvalidateUserCache(id);
 583
 0584        TempData["SuccessMessage"] = "Override обновлён";
 0585        return RedirectToAction(nameof(Edit), new { id });
 0586    }
 587
 588    [HttpPost]
 589    [ValidateAntiForgeryToken]
 590    public async Task<IActionResult> RemovePageOverride(string id, int pageId, CancellationToken ct)
 591    {
 0592        var o = await _db.UserPageOverrides.FirstOrDefaultAsync(x => x.UserId == id && x.AppPageId == pageId, ct);
 0593        if (o != null) { _db.UserPageOverrides.Remove(o); await _db.SaveChangesAsync(ct); _permissions.InvalidateUserCac
 0594        return RedirectToAction(nameof(Edit), new { id });
 0595    }
 596
 597    [HttpPost]
 598    [ValidateAntiForgeryToken]
 599    public async Task<IActionResult> SaveFieldOverride(string id, int fieldId, bool? canRead, bool? canWrite, Cancellati
 600    {
 0601        var user = await _userManager.FindByIdAsync(id);
 0602        if (user == null) return NotFound();
 603
 0604        var o = await _db.UserFieldOverrides.FirstOrDefaultAsync(x => x.UserId == id && x.AppFieldId == fieldId, ct);
 0605        var hasAny = canRead.HasValue || canWrite.HasValue;
 0606        if (o == null)
 607        {
 0608            if (!hasAny) return Ok();
 0609            o = new UserFieldOverride { UserId = id, AppFieldId = fieldId };
 0610            _db.UserFieldOverrides.Add(o);
 611        }
 0612        o.CanRead = canRead; o.CanWrite = canWrite; o.UpdatedAt = DateTime.UtcNow;
 0613        if (!hasAny) _db.UserFieldOverrides.Remove(o);
 614
 0615        await _db.SaveChangesAsync(ct);
 0616        await _audit.LogAsync(AuthorizationConstants.AuditActionUserOverrideChanged, nameof(UserFieldOverride), $"user={
 0617        _permissions.InvalidateUserCache(id);
 618
 0619        TempData["SuccessMessage"] = "Override поля обновлён";
 0620        return RedirectToAction(nameof(Edit), new { id });
 0621    }
 622
 623    [HttpPost]
 624    [ValidateAntiForgeryToken]
 625    public async Task<IActionResult> RemoveFieldOverride(string id, int fieldId, CancellationToken ct)
 626    {
 0627        var o = await _db.UserFieldOverrides.FirstOrDefaultAsync(x => x.UserId == id && x.AppFieldId == fieldId, ct);
 0628        if (o != null) { _db.UserFieldOverrides.Remove(o); await _db.SaveChangesAsync(ct); _permissions.InvalidateUserCa
 0629        return RedirectToAction(nameof(Edit), new { id });
 0630    }
 631
 632    [HttpPost]
 633    [ValidateAntiForgeryToken]
 634    public async Task<IActionResult> SaveDataScopes(string id, int[]? scopeIds, CancellationToken ct)
 635    {
 0636        var user = await _userManager.FindByIdAsync(id);
 0637        if (user == null) return NotFound();
 638
 0639        var current = await _db.UserDataScopes.Where(u => u.UserId == id).ToListAsync(ct);
 0640        var oldIds = current.Select(c => c.DataScopeId).ToList();
 0641        var newIds = (scopeIds ?? Array.Empty<int>()).Distinct().ToList();
 642
 0643        _db.RemoveRange(current.Where(c => !newIds.Contains(c.DataScopeId)));
 0644        foreach (var sid in newIds.Where(s => !oldIds.Contains(s)))
 645        {
 0646            _db.UserDataScopes.Add(new UserDataScope
 0647            {
 0648                UserId = id,
 0649                DataScopeId = sid,
 0650                AssignedAt = DateTime.UtcNow,
 0651                AssignedBy = User.Identity?.Name,
 0652            });
 653        }
 0654        await _db.SaveChangesAsync(ct);
 0655        await _audit.LogAsync(AuthorizationConstants.AuditActionUserOverrideChanged, nameof(UserDataScope), id,
 0656            System.Text.Json.JsonSerializer.Serialize(oldIds),
 0657            System.Text.Json.JsonSerializer.Serialize(newIds), ct: ct);
 0658        _permissions.InvalidateUserCache(id);
 659
 0660        TempData["SuccessMessage"] = "Наборы данных пользователя обновлены";
 0661        return RedirectToAction(nameof(Edit), new { id });
 0662    }
 663}