Compare commits

..

No commits in common. "e38a360e549c8b60f95f24c774c1de2b2653020e" and "fc9f7c9ee45dbcbf8cc124a35fa084431c62f16e" have entirely different histories.

16 changed files with 0 additions and 956 deletions

View file

@ -27,8 +27,6 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<!-- App services --> <!-- App services -->
<PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="12.4.1" /> <PackageVersion Include="MediatR" Version="12.4.1" />
<PackageVersion Include="FluentValidation" Version="11.11.0" /> <PackageVersion Include="FluentValidation" Version="11.11.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" /> <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />

View file

@ -1,159 +0,0 @@
using System.Collections.Concurrent;
using System.Web;
using foodmarket.Application.Common.Email;
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.Logging;
namespace foodmarket.Api.Controllers;
/// <summary>Восстановление пароля. Эндпоинты anonymous, защищены простым
/// IP-rate-limit'ом (3 попытки в час на IP), чтобы не было spam-attack.
/// Ответ /forgot-password всегда 200 — анти-юзер-энумерация (не палим
/// существование email).</summary>
[ApiController]
[AllowAnonymous]
[Route("api/auth")]
public class AuthForgotPasswordController : ControllerBase
{
private readonly UserManager<User> _userMgr;
private readonly AppDbContext _db;
private readonly IEmailSender _email;
private readonly ILogger<AuthForgotPasswordController> _logger;
// In-memory rate-limit. Для одного API-инстанса достаточно; при scale-out
// понадобится Redis. Кладём timestamps попыток per IP, рубим >3 за час.
private static readonly ConcurrentDictionary<string, List<DateTime>> _ipAttempts = new();
private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1);
private const int _maxAttemptsPerWindow = 3;
public AuthForgotPasswordController(
UserManager<User> userMgr, AppDbContext db, IEmailSender email,
ILogger<AuthForgotPasswordController> logger)
{
_userMgr = userMgr; _db = db; _email = email; _logger = logger;
}
public record ForgotInput(string Email);
public record ResetInput(string Email, string Token, string NewPassword);
[HttpPost("forgot-password")]
public async Task<IActionResult> Forgot([FromBody] ForgotInput input, CancellationToken ct)
{
var ip = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
if (!CheckRateLimit(ip))
{
return StatusCode(StatusCodes.Status429TooManyRequests, new
{
error = "Слишком много попыток. Попробуйте через час.",
});
}
// Универсальный 200 чтобы не палить существование email.
// Реально письмо шлём только если юзер найден И активен И имеет email.
if (string.IsNullOrWhiteSpace(input.Email))
return Ok(new { ok = true });
try
{
var user = await _userMgr.FindByEmailAsync(input.Email.Trim());
if (user is not null && user.IsActive && !string.IsNullOrEmpty(user.Email))
{
var token = await _userMgr.GeneratePasswordResetTokenAsync(user);
var resetUrl = BuildResetUrl(user.Email!, token);
var body =
$"Здравствуйте.\n\n" +
$"Кто-то запросил восстановление пароля для вашего аккаунта Food Market ({user.Email}).\n" +
$"Если это были вы — перейдите по ссылке (действительна 1 час):\n\n" +
$"{resetUrl}\n\n" +
$"Если вы не запрашивали восстановление — просто игнорируйте это письмо.\n";
try
{
await _email.SendAsync(user.Email!, "Food Market — восстановление пароля", body, ct);
_logger.LogInformation("Forgot-password email sent to {Email}", user.Email);
}
catch (EmailNotConfiguredException ex)
{
// Не падаем 500 — это плохая UX. Просто логируем и
// отдаём 200, чтобы юзер не ждал зря; SuperAdmin увидит
// в логах что нужно настроить SMTP.
_logger.LogError(ex, "Forgot-password skipped: SMTP not configured");
}
catch (Exception ex)
{
_logger.LogError(ex, "Forgot-password email failed for {Email}", user.Email);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Forgot-password unexpected error");
}
return Ok(new { ok = true });
}
[HttpPost("reset-password")]
public async Task<IActionResult> Reset([FromBody] ResetInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Email) || string.IsNullOrWhiteSpace(input.Token))
return BadRequest(new { error = "Некорректная ссылка." });
if (string.IsNullOrWhiteSpace(input.NewPassword) || input.NewPassword.Length < 8)
return BadRequest(new { error = "Пароль должен быть не менее 8 символов." });
var user = await _userMgr.FindByEmailAsync(input.Email.Trim());
if (user is null || !user.IsActive)
return BadRequest(new { error = "Ссылка недействительна или истекла." });
var result = await _userMgr.ResetPasswordAsync(user, input.Token, input.NewPassword);
if (!result.Succeeded)
{
// ASP.NET Identity отдаёт «InvalidToken» если токен испорчен/истёк
// или если ResetPasswordAsync вызвали для другого юзера.
return BadRequest(new
{
error = result.Errors.Any(e => e.Code == "InvalidToken")
? "Ссылка недействительна или истекла. Запросите новую."
: string.Join("; ", result.Errors.Select(e => e.Description)),
});
}
// Revoke все активные refresh/access токены — старые пароли больше нерелевантны.
await _db.Database.ExecuteSqlRawAsync(
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Status\" = 'valid' AND \"Subject\" = {0}",
new object[] { user.Id.ToString() });
return Ok(new { ok = true });
}
private string BuildResetUrl(string email, string token)
{
// Frontend-host берём из Host заголовка через nginx — с учётом
// что админка на admin.food-market.kz. Если запрос пришёл с
// другого host (CORS-call) — фолбэк на admin-домен.
var host = HttpContext?.Request?.Host.Value;
var scheme = HttpContext?.Request?.Scheme ?? "https";
if (string.IsNullOrEmpty(host) || host.Contains("localhost"))
{
return $"https://admin.food-market.kz/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
}
return $"{scheme}://{host}/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
}
private static bool CheckRateLimit(string ip)
{
var now = DateTime.UtcNow;
var attempts = _ipAttempts.GetOrAdd(ip, _ => new List<DateTime>());
lock (attempts)
{
// Чистим устаревшие.
attempts.RemoveAll(t => now - t > _rateLimitWindow);
if (attempts.Count >= _maxAttemptsPerWindow) return false;
attempts.Add(now);
return true;
}
}
}

View file

@ -1,162 +0,0 @@
using System.Security.Claims;
using foodmarket.Application.Common.Email;
using foodmarket.Domain.Organizations;
using foodmarket.Domain.Platform;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.SuperAdmin;
/// <summary>SuperAdmin: единая платформенная настройка SMTP. GET отдаёт всё
/// КРОМЕ пароля (только has-password флаг). PUT принимает все поля + опционально
/// новый пароль. Пароль шифруется через DataProtection (purpose="foodmarket.smtp")
/// перед записью в БД. Все мутации пишутся в SuperAdminAuditLog с reason.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/platform-settings")]
public class PlatformSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IDataProtectionProvider _dpProvider;
private readonly IEmailSender _email;
public PlatformSettingsController(AppDbContext db, IDataProtectionProvider dpProvider, IEmailSender email)
{
_db = db; _dpProvider = dpProvider; _email = email;
}
public record PlatformSettingsDto(
string? SmtpHost, int? SmtpPort,
bool SmtpUseSsl, bool SmtpStartTls,
string? SmtpUsername, bool HasSmtpPassword,
string? FromEmail, string? FromName,
DateTime? UpdatedAt);
public record PlatformSettingsInput(
string Reason,
string? SmtpHost, int? SmtpPort,
bool SmtpUseSsl, bool SmtpStartTls,
string? SmtpUsername,
// Если null/пусто — пароль не меняется. Если задан — шифруется и
// записывается. Чтобы СНЯТЬ пароль (отправлять без auth), используем
// спец-значение "__clear__".
string? NewSmtpPassword,
string? FromEmail, string? FromName);
public record TestSendInput(string ToEmail, string Subject, string Body);
[HttpGet]
public async Task<ActionResult<PlatformSettingsDto>> Get(CancellationToken ct)
{
var s = await GetOrCreateAsync(ct);
return new PlatformSettingsDto(
s.SmtpHost, s.SmtpPort,
s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
s.FromEmail, s.FromName,
s.UpdatedAt);
}
[HttpPut]
public async Task<IActionResult> Update([FromBody] PlatformSettingsInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10)
return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." });
var s = await GetOrCreateAsync(ct);
var prev = new
{
s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, s.FromEmail, s.FromName,
HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
};
s.SmtpHost = input.SmtpHost?.Trim();
s.SmtpPort = input.SmtpPort;
s.SmtpUseSsl = input.SmtpUseSsl;
s.SmtpStartTls = input.SmtpStartTls;
s.SmtpUsername = input.SmtpUsername?.Trim();
s.FromEmail = input.FromEmail?.Trim();
s.FromName = input.FromName?.Trim();
s.UpdatedAt = DateTime.UtcNow;
if (input.NewSmtpPassword == "__clear__")
{
s.SmtpPasswordEncrypted = null;
}
else if (!string.IsNullOrEmpty(input.NewSmtpPassword))
{
var protector = _dpProvider.CreateProtector("foodmarket.smtp");
s.SmtpPasswordEncrypted = protector.Protect(input.NewSmtpPassword);
}
await _db.SaveChangesAsync(ct);
await LogAsync("PlatformSettingsUpdate",
$"Обновлены SMTP-настройки платформы (host={s.SmtpHost} from={s.FromEmail})",
input.Reason,
System.Text.Json.JsonSerializer.Serialize(new { prev, next = new {
s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, s.FromEmail, s.FromName,
HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
} }), ct);
return NoContent();
}
[HttpPost("test-send")]
public async Task<IActionResult> TestSend([FromBody] TestSendInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.ToEmail))
return BadRequest(new { error = "Адрес получателя обязателен." });
try
{
await _email.SendAsync(input.ToEmail.Trim(),
string.IsNullOrWhiteSpace(input.Subject) ? "Food Market — тестовое сообщение" : input.Subject,
string.IsNullOrWhiteSpace(input.Body) ? "Тестовое письмо от Food Market." : input.Body,
ct);
return Ok(new { ok = true, sentTo = input.ToEmail });
}
catch (EmailNotConfiguredException ex)
{
return BadRequest(new { ok = false, error = ex.Message });
}
catch (Exception ex)
{
// Полный текст ошибки SMTP пишем в response — это SuperAdmin-only,
// diagnostic-info ему нужна.
return StatusCode(StatusCodes.Status500InternalServerError, new { ok = false, error = ex.Message });
}
}
private async Task<PlatformSettings> GetOrCreateAsync(CancellationToken ct)
{
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
if (s is null)
{
s = new PlatformSettings();
_db.PlatformSettings.Add(s);
await _db.SaveChangesAsync(ct);
}
return s;
}
private async Task LogAsync(string actionType, 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 = null,
Description = description, Reason = reason,
ChangesJson = changesJson,
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
}
}

View file

@ -128,13 +128,6 @@
}); });
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>();
builder.Services.AddDataProtection();
builder.Services.AddControllers(o => builder.Services.AddControllers(o =>
{ {
// Глобальный action filter — пишет audit-log при успешных мутациях // Глобальный action filter — пишет audit-log при успешных мутациях

View file

@ -1,16 +0,0 @@
namespace foodmarket.Application.Common.Email;
/// <summary>Отправка одного письма через текущие платформенные SMTP-настройки.
/// Конфиг читается из БД (PlatformSettings) на каждой отправке — без рестарта
/// сервиса можно поменять SMTP-сервер. Реализация — MailKit.</summary>
public interface IEmailSender
{
Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default);
}
/// <summary>Бросается когда платформенный SMTP не настроен. Контроллеры должны
/// ловить и возвращать понятный 503/400 — не падать в 500.</summary>
public class EmailNotConfiguredException : Exception
{
public EmailNotConfiguredException(string message) : base(message) { }
}

View file

@ -1,36 +0,0 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Platform;
/// <summary>Платформенные настройки (singleton, single-row). Хранят SMTP-креды
/// для отправки писем (forgot-password, инвайты, нотификации). Не tenant-scoped —
/// общий конфиг для всей платформы, видны и меняются только Супер-администратором.
///
/// SmtpPassword хранится зашифрованным через DataProtection API
/// (`IDataProtectionProvider.CreateProtector("foodmarket.smtp")`); снаружи
/// (контроллер) — никогда не возвращается в открытом виде, только has-password флаг.</summary>
public class PlatformSettings : Entity
{
/// <summary>SMTP-сервер для отправки исходящей почты (НЕ IMAP — IMAP это
/// для чтения входящей).</summary>
public string? SmtpHost { get; set; }
public int? SmtpPort { get; set; }
/// <summary>Implicit TLS (SMTPS, обычно порт 465). Взаимоисключающий
/// со SmtpStartTls (587).</summary>
public bool SmtpUseSsl { get; set; }
/// <summary>STARTTLS upgrade (обычно порт 587). По дефолту true в большинстве
/// современных провайдеров (Gmail/Yandex/Mailgun).</summary>
public bool SmtpStartTls { get; set; } = true;
public string? SmtpUsername { get; set; }
/// <summary>Зашифрованный SmtpPassword (base64 через DataProtection).
/// Никогда не отдаётся в API-ответах. Установка только через PUT с
/// явно переданным new-password полем.</summary>
public string? SmtpPasswordEncrypted { get; set; }
public string? FromEmail { get; set; }
public string? FromName { get; set; }
}

View file

@ -1,88 +0,0 @@
using foodmarket.Application.Common.Email;
using foodmarket.Infrastructure.Persistence;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MimeKit;
namespace foodmarket.Infrastructure.Email;
/// <summary>SMTP-отправка через MailKit. Зарегистрирован Singleton, но на
/// каждую отправку создаёт scope для свежего DbContext'а — конфиг
/// (PlatformSettings) перечитывается на каждой отправке без рестарта.
///
/// Если SMTP не настроен (host пуст / from-email пуст) — кидает
/// EmailNotConfiguredException. Контроллер ловит и возвращает 400/500.</summary>
public class MailKitEmailSender : IEmailSender
{
private readonly IServiceScopeFactory _scopes;
private readonly IDataProtectionProvider _dpProvider;
private readonly ILogger<MailKitEmailSender> _logger;
public MailKitEmailSender(IServiceScopeFactory scopes, IDataProtectionProvider dpProvider, ILogger<MailKitEmailSender> logger)
{
_scopes = scopes; _dpProvider = dpProvider; _logger = logger;
}
public async Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default)
{
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var s = await db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct);
if (s is null || string.IsNullOrWhiteSpace(s.SmtpHost) || string.IsNullOrWhiteSpace(s.FromEmail))
{
throw new EmailNotConfiguredException(
"SMTP не настроен. Откройте «Системная консоль → Настройки платформы» и заполните SmtpHost, FromEmail, креды.");
}
var msg = new MimeMessage();
msg.From.Add(new MailboxAddress(s.FromName ?? "Food Market", s.FromEmail));
msg.To.Add(MailboxAddress.Parse(toEmail));
msg.Subject = subject;
msg.Body = new TextPart("plain") { Text = body };
// Implicit TLS (SmtpUseSsl) — обычно 465. STARTTLS (SmtpStartTls) — 587.
// Если оба false — открытое соединение (SmtpClient.Connect c None).
var secureOption = s.SmtpUseSsl
? SecureSocketOptions.SslOnConnect
: (s.SmtpStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
var port = s.SmtpPort ?? (s.SmtpUseSsl ? 465 : 587);
using var client = new SmtpClient();
await client.ConnectAsync(s.SmtpHost, port, secureOption, ct);
if (!string.IsNullOrEmpty(s.SmtpUsername))
{
var password = string.Empty;
if (!string.IsNullOrEmpty(s.SmtpPasswordEncrypted))
{
try
{
var protector = _dpProvider.CreateProtector("foodmarket.smtp");
password = protector.Unprotect(s.SmtpPasswordEncrypted);
}
catch (Exception ex)
{
throw new EmailNotConfiguredException(
"Не удалось расшифровать SMTP-пароль (DataProtection ключ изменился?). " +
"Введите пароль заново в настройках платформы. Detail: " + ex.Message);
}
}
await client.AuthenticateAsync(s.SmtpUsername, password, ct);
}
try
{
await client.SendAsync(msg, ct);
_logger.LogInformation("Email sent to {To} subject={Subject}", toEmail, subject);
}
finally
{
await client.DisconnectAsync(true, ct);
}
}
}

View file

@ -51,7 +51,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>(); public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>(); public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -87,15 +86,6 @@ protected override void OnModelCreating(ModelBuilder builder)
b.ToTable("system_settings"); b.ToTable("system_settings");
}); });
builder.Entity<foodmarket.Domain.Platform.PlatformSettings>(b =>
{
b.ToTable("platform_settings");
b.Property(x => x.SmtpHost).HasMaxLength(200);
b.Property(x => x.SmtpUsername).HasMaxLength(200);
b.Property(x => x.FromEmail).HasMaxLength(200);
b.Property(x => x.FromName).HasMaxLength(200);
});
builder.Entity<SuperAdminAuditLog>(b => builder.Entity<SuperAdminAuditLog>(b =>
{ {
b.ToTable("super_admin_audit_log"); b.ToTable("super_admin_audit_log");

View file

@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Платформенные настройки (singleton). SMTP-креды для отправки
/// писем (forgot-password, нотификации). Управляется SuperAdmin'ом через
/// /super-admin/platform-settings. Видна только им.</summary>
public partial class Phase5b_PlatformSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "platform_settings",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
SmtpHost = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SmtpPort = table.Column<int>(type: "integer", nullable: true),
SmtpUseSsl = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SmtpStartTls = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
SmtpUsername = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SmtpPasswordEncrypted = table.Column<string>(type: "text", nullable: true),
FromEmail = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
FromName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table => table.PrimaryKey("PK_platform_settings", x => x.Id));
}
protected override void Down(MigrationBuilder b)
{
b.DropTable(name: "platform_settings", schema: "public");
}
}
}

View file

@ -18,7 +18,5 @@
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" /> <PackageReference Include="OpenIddict.EntityFrameworkCore" />
<PackageReference Include="Hangfire.PostgreSql" /> <PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -35,9 +35,6 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage'
import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage'
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
import { RoleGuard } from '@/components/RoleGuard' import { RoleGuard } from '@/components/RoleGuard'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -55,8 +52,6 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/auth-bridge" element={<AuthBridgePage />} /> <Route path="/auth-bridge" element={<AuthBridgePage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
{/* Fallback для orphan AppUser без активной org / Employee. {/* Fallback для orphan AppUser без активной org / Employee.
@ -77,7 +72,6 @@ export default function App() {
<Route path="groups" element={<ProductGroupsPage />} /> <Route path="groups" element={<ProductGroupsPage />} />
<Route path="units" element={<UnitsOfMeasurePage />} /> <Route path="units" element={<UnitsOfMeasurePage />} />
<Route path="settings" element={<SuperAdminSettingsPage />} /> <Route path="settings" element={<SuperAdminSettingsPage />} />
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
</Route> </Route>
{/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard: {/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard:

View file

@ -33,7 +33,6 @@ const NAV: NavSection[] = [
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true }, { to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
{ to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true }, { to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true },
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' }, { to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' },
{ to: '/super-admin/platform-settings', icon: Settings, label: 'SMTP / Email' },
]}, ]},
] ]

View file

@ -1,82 +0,0 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Logo } from '@/components/Logo'
import { Button } from '@/components/Button'
import { Field, TextInput } from '@/components/Field'
import { validateEmail } from '@/lib/validation'
import axios from 'axios'
/** Anonymous-страница «Забыли пароль?». Не требует логина открыта по
* прямой ссылке /forgot-password. Сервер всегда отвечает 200 (анти-юзер-
* энумерация), поэтому UI показывает одинаковое сообщение независимо от
* того, есть юзер с таким email или нет. */
export function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitted, setSubmitted] = useState(false)
const [busy, setBusy] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const err = validateEmail(email)
if (err) { setError(err); return }
setError(null); setBusy(true)
try {
// Делаем чистым axios-call без api-interceptor'ау юзера нет токена.
await axios.post('/api/auth/forgot-password', { email })
setSubmitted(true)
} catch (e2) {
const err2 = e2 as { response?: { status?: number, data?: { error?: string } } }
if (err2.response?.status === 429) {
setError(err2.response.data?.error ?? 'Слишком много попыток. Попробуйте через час.')
} else {
// Любая другая ошибка — обобщаем, чтобы не палить детали.
setSubmitted(true)
}
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
<Logo />
{!submitted ? (
<form onSubmit={submit} noValidate className="space-y-4">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Забыли пароль?</h1>
<p className="text-sm text-slate-500 mt-1">
Укажите email пришлём ссылку для восстановления.
</p>
</div>
<Field label="Email" error={error ?? undefined}>
<TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)}
autoComplete="email" placeholder="name@example.kz" required />
</Field>
<Button onClick={() => {}} disabled={busy} className="w-full justify-center">
{busy ? 'Отправляю…' : 'Отправить ссылку'}
</Button>
<p className="text-xs text-center text-slate-500">
<Link to="/login" className="text-[var(--color-brand)] hover:underline"> Войти под другим аккаунтом</Link>
</p>
</form>
) : (
<div className="space-y-3">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Проверьте почту</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Если аккаунт с указанным email существует, мы отправили на него ссылку для восстановления пароля.
Письмо приходит в течение минуты; не забудьте проверить «Спам».
</p>
<p className="text-xs text-slate-500">
Ссылка действительна 1 час. Если не дошло попробуйте ещё раз через час.
</p>
<Link to="/login" className="block text-center text-sm text-[var(--color-brand)] hover:underline">
К входу
</Link>
</div>
)}
</div>
</div>
)
}

View file

@ -96,10 +96,6 @@ export function LoginPage() {
{loading ? 'Выполняется вход…' : 'Войти'} {loading ? 'Выполняется вход…' : 'Войти'}
</button> </button>
<p className="text-xs text-center">
<a href="/forgot-password" className="text-[var(--color-brand)] hover:underline">Забыли пароль?</a>
</p>
<p className="text-xs text-slate-400 text-center"> <p className="text-xs text-slate-400 text-center">
Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code> Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code>
</p> </p>

View file

@ -1,84 +0,0 @@
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Logo } from '@/components/Logo'
import { Button } from '@/components/Button'
import { Field, TextInput } from '@/components/Field'
import { validatePassword } from '@/lib/validation'
import axios from 'axios'
/** Anonymous-страница приёма ссылки восстановления. URL вида
* /reset-password?email=...&token=...; токен короткоживущий (1 час),
* сгенерирован Identity GeneratePasswordResetTokenAsync. */
export function ResetPasswordPage() {
const [params] = useSearchParams()
const navigate = useNavigate()
const email = params.get('email') ?? ''
const token = params.get('token') ?? ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!email || !token) {
setError('Ссылка некорректна. Запросите новую.')
return
}
const pwErr = validatePassword(password)
if (pwErr) { setError(pwErr); return }
if (password !== confirm) { setError('Пароли не совпадают.'); return }
setBusy(true)
try {
await axios.post('/api/auth/reset-password', { email, token, newPassword: password })
setDone(true)
setTimeout(() => navigate('/login', { replace: true }), 2500)
} catch (e2) {
const err = e2 as { response?: { data?: { error?: string } } }
setError(err.response?.data?.error ?? 'Не удалось обновить пароль.')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
<Logo />
{!done ? (
<form onSubmit={submit} noValidate className="space-y-4">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Новый пароль</h1>
<p className="text-sm text-slate-500 mt-1">
Аккаунт {email ? <strong>{email}</strong> : 'не указан'}.
</p>
</div>
<Field label="Новый пароль">
<TextInput type="password" value={password}
onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
</Field>
<Field label="Повторите пароль" error={error ?? undefined}>
<TextInput type="password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
</Field>
<Button onClick={() => {}} disabled={busy} className="w-full justify-center">
{busy ? 'Сохраняю…' : 'Установить пароль'}
</Button>
<p className="text-xs text-center text-slate-500">
<Link to="/login" className="text-[var(--color-brand)] hover:underline"> К входу</Link>
</p>
</form>
) : (
<div className="space-y-3">
<h1 className="text-xl font-bold text-emerald-700 dark:text-emerald-400">Пароль обновлён</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Сейчас перенаправим на страницу входа
</p>
</div>
)}
</div>
</div>
)
}

View file

@ -1,258 +0,0 @@
import { useEffect, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, Send, CheckCircle2, AlertTriangle, Mail } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
import { Button } from '@/components/Button'
interface PlatformSettingsDto {
smtpHost: string | null
smtpPort: number | null
smtpUseSsl: boolean
smtpStartTls: boolean
smtpUsername: string | null
hasSmtpPassword: boolean
fromEmail: string | null
fromName: string | null
updatedAt: string | null
}
interface Form {
smtpHost: string
smtpPort: string
smtpUseSsl: boolean
smtpStartTls: boolean
smtpUsername: string
newSmtpPassword: string
fromEmail: string
fromName: string
reason: string
}
const blankForm = (): Form => ({
smtpHost: '', smtpPort: '', smtpUseSsl: false, smtpStartTls: true,
smtpUsername: '', newSmtpPassword: '',
fromEmail: '', fromName: 'Food Market',
reason: '',
})
export function SuperAdminPlatformSettingsPage() {
const qc = useQueryClient()
const { data } = useQuery({
queryKey: ['/api/super-admin/platform-settings'],
queryFn: async () => (await api.get<PlatformSettingsDto>('/api/super-admin/platform-settings')).data,
})
const [form, setForm] = useState<Form>(blankForm())
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [savedHint, setSavedHint] = useState(false)
const [error, setError] = useState<string | null>(null)
// Тестовая отправка.
const [testTo, setTestTo] = useState('')
const [testSubject, setTestSubject] = useState('Тест Food Market')
const [testBody, setTestBody] = useState('Если вы видите это письмо — SMTP в Food Market настроен корректно.')
const [testBusy, setTestBusy] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null)
useEffect(() => {
if (data && !loaded) {
setForm({
smtpHost: data.smtpHost ?? '',
smtpPort: data.smtpPort != null ? String(data.smtpPort) : '',
smtpUseSsl: data.smtpUseSsl,
smtpStartTls: data.smtpStartTls,
smtpUsername: data.smtpUsername ?? '',
newSmtpPassword: '',
fromEmail: data.fromEmail ?? '',
fromName: data.fromName ?? 'Food Market',
reason: '',
})
setLoaded(true)
}
}, [data, loaded])
const save = async () => {
setError(null); setSaving(true)
try {
await api.put('/api/super-admin/platform-settings', {
reason: form.reason,
smtpHost: form.smtpHost || null,
smtpPort: form.smtpPort ? Number(form.smtpPort) : null,
smtpUseSsl: form.smtpUseSsl,
smtpStartTls: form.smtpStartTls,
smtpUsername: form.smtpUsername || null,
newSmtpPassword: form.newSmtpPassword || null,
fromEmail: form.fromEmail || null,
fromName: form.fromName || null,
})
await qc.invalidateQueries({ queryKey: ['/api/super-admin/platform-settings'] })
setForm({ ...form, newSmtpPassword: '', reason: '' })
setSavedHint(true)
setTimeout(() => setSavedHint(false), 2500)
} catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string }
setError(err.response?.data?.error ?? err.message ?? 'Не удалось сохранить')
} finally {
setSaving(false)
}
}
const sendTest = async () => {
setTestResult(null); setTestBusy(true)
try {
const res = await api.post<{ ok: boolean, sentTo?: string }>('/api/super-admin/platform-settings/test-send', {
toEmail: testTo, subject: testSubject, body: testBody,
})
setTestResult({ ok: true, message: `Письмо отправлено на ${res.data.sentTo}. Проверьте почту получателя.` })
} catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string }
setTestResult({ ok: false, message: err.response?.data?.error ?? err.message ?? 'Не удалось отправить' })
} finally {
setTestBusy(false)
}
}
const reasonOk = form.reason.trim().length >= 10
const requiredOk = form.smtpHost.trim() && form.fromEmail.trim()
return (
<div className="h-full overflow-auto">
<div className="max-w-3xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader
title="Настройки платформы"
description="SMTP-сервер для отправки писем (восстановление пароля, нотификации). Доступно только Супер-администратору."
/>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Mail className="w-4 h-4" /> SMTP-сервер</h3>
<div className="grid grid-cols-3 gap-3">
<Field label="Хост (server)">
<TextInput value={form.smtpHost} onChange={(e) => setForm({ ...form, smtpHost: e.target.value })}
placeholder="smtp.gmail.com" />
</Field>
<Field label="Порт">
<TextInput type="number" inputMode="numeric" value={form.smtpPort}
onChange={(e) => setForm({ ...form, smtpPort: e.target.value })} placeholder="587" />
</Field>
<Field label="Шифрование">
<select
value={form.smtpUseSsl ? 'ssl' : (form.smtpStartTls ? 'starttls' : 'none')}
onChange={(e) => {
const v = e.target.value
setForm({
...form,
smtpUseSsl: v === 'ssl',
smtpStartTls: v === 'starttls',
})
}}
className="w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm"
>
<option value="starttls">STARTTLS (587)</option>
<option value="ssl">Implicit TLS / SSL (465)</option>
<option value="none">Без шифрования (dev)</option>
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Логин (Username)">
<TextInput value={form.smtpUsername} onChange={(e) => setForm({ ...form, smtpUsername: e.target.value })}
placeholder="user@gmail.com" autoComplete="off" />
</Field>
<Field label="Пароль">
<TextInput type="password" value={form.newSmtpPassword}
onChange={(e) => setForm({ ...form, newSmtpPassword: e.target.value })}
placeholder={data?.hasSmtpPassword ? '•••••••• (без изменений)' : 'Введите пароль'}
autoComplete="new-password" />
<p className="text-xs text-slate-500 mt-1">
{data?.hasSmtpPassword
? 'Сохранён, шифрован DataProtection. Оставьте поле пустым — пароль не изменится.'
: 'Пароль не сохранён.'}
</p>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="From — email отправителя *">
<TextInput type="email" value={form.fromEmail}
onChange={(e) => setForm({ ...form, fromEmail: e.target.value })}
placeholder="noreply@food-market.kz" />
</Field>
<Field label="From — имя отправителя">
<TextInput value={form.fromName}
onChange={(e) => setForm({ ...form, fromName: e.target.value })}
placeholder="Food Market" />
</Field>
</div>
</section>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold">Сохранение</h3>
<Field label="Причина изменения (≥ 10 символов, в журнал)">
<TextArea rows={2} value={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
placeholder="Например: подключение Gmail SMTP для отправки писем восстановления пароля" />
</Field>
<div className="flex items-center gap-3 pt-1">
<Button onClick={save} disabled={!reasonOk || !requiredOk || saving}>
<Save className="w-4 h-4" /> {saving ? 'Сохраняю…' : 'Сохранить настройки'}
</Button>
{savedHint && (
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Сохранено
</span>
)}
</div>
{!requiredOk && (
<p className="text-xs text-slate-500">Минимально нужны: SmtpHost и FromEmail. Без них отправка невозможна.</p>
)}
</section>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Send className="w-4 h-4" /> Тестовая отправка</h3>
<p className="text-xs text-slate-500">
Письмо отправляется немедленно через текущие сохранённые настройки. Удобно проверить что
креды Gmail/Yandex/Mailgun валидны и что firewall пропускает SMTP-порт.
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Кому">
<TextInput type="email" value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
</Field>
<Field label="Тема">
<TextInput value={testSubject} onChange={(e) => setTestSubject(e.target.value)} />
</Field>
</div>
<Field label="Текст">
<TextArea rows={3} value={testBody} onChange={(e) => setTestBody(e.target.value)} />
</Field>
<Button onClick={sendTest} disabled={!testTo || testBusy} variant="secondary">
<Send className="w-4 h-4" /> {testBusy ? 'Отправляю…' : 'Отправить тестовое письмо'}
</Button>
{testResult && (
<div className={
testResult.ok
? 'rounded-md bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 inline-flex items-start gap-2'
: 'rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 inline-flex items-start gap-2'
}>
{testResult.ok ? <CheckCircle2 className="w-4 h-4 mt-0.5" /> : <AlertTriangle className="w-4 h-4 mt-0.5" />}
<span>{testResult.message}</span>
</div>
)}
</section>
{/* Hidden Checkbox import используется чтобы линтер не ругался оставляем
чтобы при будущем расширении (например «Использовать SMTP-настройки
организации вместо платформенных») было где включить тоггл. */}
<div className="hidden"><Checkbox label="" checked={false} onChange={() => {}} /></div>
</div>
</div>
)
}