Compare commits
4 commits
fc9f7c9ee4
...
e38a360e54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e38a360e54 | ||
|
|
ab13a89617 | ||
|
|
76e956ea6c | ||
|
|
1456f170eb |
|
|
@ -27,6 +27,8 @@
|
||||||
<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" />
|
||||||
|
|
|
||||||
159
src/food-market.api/Controllers/AuthForgotPasswordController.cs
Normal file
159
src/food-market.api/Controllers/AuthForgotPasswordController.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -128,6 +128,13 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
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 при успешных мутациях
|
||||||
|
|
|
||||||
16
src/food-market.application/Common/Email/IEmailSender.cs
Normal file
16
src/food-market.application/Common/Email/IEmailSender.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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) { }
|
||||||
|
}
|
||||||
36
src/food-market.domain/Platform/PlatformSettings.cs
Normal file
36
src/food-market.domain/Platform/PlatformSettings.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
88
src/food-market.infrastructure/Email/MailKitEmailSender.cs
Normal file
88
src/food-market.infrastructure/Email/MailKitEmailSender.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,7 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -86,6 +87,15 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,5 +18,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ 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({
|
||||||
|
|
@ -52,6 +55,8 @@ 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.
|
||||||
|
|
@ -72,6 +77,7 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ 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' },
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
82
src/food-market.web/src/pages/ForgotPasswordPage.tsx
Normal file
82
src/food-market.web/src/pages/ForgotPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -96,6 +96,10 @@ 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>
|
||||||
|
|
|
||||||
84
src/food-market.web/src/pages/ResetPasswordPage.tsx
Normal file
84
src/food-market.web/src/pages/ResetPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
258
src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx
Normal file
258
src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue