diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md
index 33bb01d..13c8959 100644
--- a/docs/quick-login-progress.md
+++ b/docs/quick-login-progress.md
@@ -18,7 +18,7 @@
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
работает как второй вход рядом со своим паролем. Неактивные/без-org
пользователи — отшиваются как раньше. Rate-limit не ослаблять.
-- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`
+- [x] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts`
(анонимный, 404 если выключен). Порядок: SuperAdmin → демо-* → остальные
«человеческие». Autotest-патéрны (cat31-*, visual-*, factory-smoke-*, mt6*,
quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*,
diff --git a/src/food-market.api/Controllers/QuickLoginController.cs b/src/food-market.api/Controllers/QuickLoginController.cs
new file mode 100644
index 0000000..b9eda6e
--- /dev/null
+++ b/src/food-market.api/Controllers/QuickLoginController.cs
@@ -0,0 +1,184 @@
+using foodmarket.Api.Infrastructure;
+using foodmarket.Infrastructure.Identity;
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace foodmarket.Api.Controllers;
+
+/// Dev-only список аккаунтов для «быстрого входа» на тестовом стенде.
+/// Анонимный, но доступен ТОЛЬКО когда
+/// = true. В проде отдаёт 404 — ни отфильтрованных, ни пустых ответов, чтобы
+/// по наличию эндпоинта нельзя было вычислить среду.
+///
+/// Что отдаём: активных «человеческих» пользователей — SuperAdmin, затем
+/// demo-* (seeded), затем остальные. Автотестовые (с числовым суффиксом или
+/// служебным префиксом) отсеиваются по шаблонам из .
+[ApiController]
+[AllowAnonymous]
+[Route("api/dev/quick-login")]
+public class QuickLoginController : ControllerBase
+{
+ private readonly AppDbContext _db;
+ private readonly UserManager _userManager;
+ private readonly QuickLoginOptions _opts;
+ private readonly string? _issuer;
+
+ public QuickLoginController(
+ AppDbContext db,
+ UserManager userManager,
+ IOptions opts,
+ IConfiguration cfg)
+ {
+ _db = db;
+ _userManager = userManager;
+ _opts = opts.Value;
+ _issuer = cfg["OpenIddict:Issuer"];
+ }
+
+ public record QuickLoginAccount(
+ string Email,
+ string FullName,
+ string? Role,
+ string? OrganizationName,
+ bool IsSuperAdmin,
+ bool IsDemo);
+
+ /// Шаблоны логинов, которые считаем автотестовыми и не показываем
+ /// в списке. Проверка — case-insensitive, по началу email'а.
+ private static readonly string[] AutoTestPatterns =
+ {
+ "cat31-", "cat32-", "cat33-", "cat34-", "cat35-",
+ "visual-", "factory-smoke-", "factory-",
+ "mt61-", "mt62-", "mt63-",
+ "doc41-", "doc42-", "doc43-", "doc44-", "doc45-", "doc46-", "doc47-", "doc48-",
+ "auth21-", "auth22-", "auth23-", "auth24-",
+ "rep51-", "rep52-", "rep53-", "rep54-",
+ "rt81-", "rt82-", "rt83-", "rt84-",
+ "wiz91-", "wiz92-", "wiz93-", "wiz94-", "wiz95-", "wiz-",
+ "quality-", "i18n71-", "i18n72-", "twofa73-",
+ "a11y-", "mob14", "mob15", "iso",
+ "axdet-", "smoke-", "ratetest-", "perf-", "probe-",
+ "load-sales-", "load-", "s19-test-",
+ "admin-", "cashier-", "keeper-", // seeded test triples по id
+ };
+
+ /// Email-домены, которые считаем «служебными» для автотестов.
+ /// На стенде реальные юзеры создаются только с @food-market.local и
+ /// @demo-market.local, остальное — автоматика.
+ private static readonly string[] AutoTestDomains =
+ {
+ "@e.kz", // короткие diagnostic-probes (dn29418@e.kz и т.п.)
+ "@test-fm.local", // quality-watchdog
+ };
+
+ [HttpGet("accounts")]
+ public async Task GetAccounts([FromQuery] string? q, CancellationToken ct = default)
+ {
+ if (!_opts.EffectivelyEnabled(_issuer, out _))
+ {
+ // 404 — не 403: наличие эндпоинта не должно palятся в проде. По
+ // шаблону Admin-only эндпоинтов, где отключение = скрытие.
+ return NotFound();
+ }
+
+ // Активные + с org (SuperAdmin — без org). IsActive=false не пускаем,
+ // как и в password-grant: быстрый вход для «уволенного» = бессмыслица.
+ var users = await _db.Users
+ .IgnoreQueryFilters()
+ .Where(u => u.IsActive)
+ .Select(u => new
+ {
+ u.Id,
+ u.UserName,
+ u.Email,
+ u.FullName,
+ u.OrganizationId,
+ OrgName = u.OrganizationId == null
+ ? null
+ : _db.Organizations.IgnoreQueryFilters()
+ .Where(o => o.Id == u.OrganizationId && !o.IsArchived)
+ .Select(o => o.Name).FirstOrDefault(),
+ // Роль в organization — через Employee.RoleId → EmployeeRole.Name.
+ // Берём первую активную (у юзера обычно одна Employee-запись).
+ RoleName = _db.Employees.IgnoreQueryFilters()
+ .Where(e => e.UserId == u.Id && e.IsActive && !e.IsDeleted)
+ .Select(e => e.Role.Name)
+ .FirstOrDefault(),
+ })
+ .ToListAsync(ct);
+
+ // ASP.NET Identity role: SuperAdmin не живёт в Employee, а в AspNetUserRoles.
+ // Делаем отдельный проход — JOIN через UserManager, не EF-рефлексией.
+ var superAdminUserIds = (await _userManager.GetUsersInRoleAsync(SystemRoles.SuperAdmin))
+ .Select(u => u.Id)
+ .ToHashSet();
+
+ var result = users
+ .Where(u => !IsAutoTest(u.Email ?? u.UserName ?? ""))
+ // SuperAdmin и demo-* остаются даже без OrgName (SuperAdmin без org'и).
+ .Select(u =>
+ {
+ var isSa = superAdminUserIds.Contains(u.Id);
+ var isDemo = IsDemoAccount(u.Email ?? u.UserName ?? "");
+ string? role = isSa ? "SuperAdmin" : (u.RoleName ?? null);
+ return new QuickLoginAccount(
+ Email: u.Email ?? u.UserName ?? "",
+ FullName: string.IsNullOrWhiteSpace(u.FullName) ? (u.Email ?? "") : u.FullName,
+ Role: role,
+ OrganizationName: isSa ? "Платформа" : u.OrgName,
+ IsSuperAdmin: isSa,
+ IsDemo: isDemo);
+ })
+ .Where(a =>
+ {
+ if (string.IsNullOrWhiteSpace(q)) return true;
+ var needle = q.Trim().ToLowerInvariant();
+ return a.Email.ToLowerInvariant().Contains(needle)
+ || (a.FullName ?? "").ToLowerInvariant().Contains(needle)
+ || (a.OrganizationName ?? "").ToLowerInvariant().Contains(needle)
+ || (a.Role ?? "").ToLowerInvariant().Contains(needle);
+ })
+ // Порядок: SuperAdmin → demo → остальные (по email для стабильности).
+ .OrderByDescending(a => a.IsSuperAdmin)
+ .ThenByDescending(a => a.IsDemo)
+ .ThenBy(a => a.Email)
+ .Take(50)
+ .ToList();
+
+ return Ok(result);
+ }
+
+ /// Автотестовые юзеры: длинный числовой суффикс (10+ цифр подряд)
+ /// или email начинается с одного из .
+ /// Исключения — demo-admin@.../demo-cashier@.../demo-keeper@...
+ /// обрабатываются , они НЕ matchат ни один из
+ /// autotest-префиксов (нет "admin-", только "demo-admin@").
+ private static bool IsAutoTest(string email)
+ {
+ if (string.IsNullOrEmpty(email)) return true;
+ var lower = email.ToLowerInvariant();
+
+ // Длинный числовой суффикс (миллисекундный timestamp 13 цифр, или 10+ цифр
+ // в середине). Любой email вида что-то-1780849101465@... считаем автотестом.
+ var m = System.Text.RegularExpressions.Regex.Match(lower, "[0-9]{10,}");
+ if (m.Success) return true;
+
+ foreach (var p in AutoTestPatterns)
+ {
+ if (lower.StartsWith(p)) return true;
+ }
+ foreach (var d in AutoTestDomains)
+ {
+ if (lower.EndsWith(d)) return true;
+ }
+ return false;
+ }
+
+ private static bool IsDemoAccount(string email) =>
+ email.StartsWith("demo-", StringComparison.OrdinalIgnoreCase)
+ && email.Contains("@food-market.local", StringComparison.OrdinalIgnoreCase);
+}