Compare commits
No commits in common. "e38a360e549c8b60f95f24c774c1de2b2653020e" and "fc9f7c9ee45dbcbf8cc124a35fa084431c62f16e" have entirely different histories.
e38a360e54
...
fc9f7c9ee4
|
|
@ -27,8 +27,6 @@
|
|||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
|
||||
<!-- 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="FluentValidation" Version="11.11.0" />
|
||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -128,13 +128,6 @@
|
|||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
// Глобальный action filter — пишет audit-log при успешных мутациях
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,6 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
|
||||
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
|
||||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||
public DbSet<foodmarket.Domain.Platform.PlatformSettings> PlatformSettings => Set<foodmarket.Domain.Platform.PlatformSettings>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
|
@ -87,15 +86,6 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
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 =>
|
||||
{
|
||||
b.ToTable("super_admin_audit_log");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,7 +18,5 @@
|
|||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
|
||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ import { TenantRouteGuard } from '@/components/TenantRouteGuard'
|
|||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
|
||||
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'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -55,8 +52,6 @@ export default function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/auth-bridge" element={<AuthBridgePage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* Fallback для orphan AppUser без активной org / Employee.
|
||||
|
|
@ -77,7 +72,6 @@ export default function App() {
|
|||
<Route path="groups" element={<ProductGroupsPage />} />
|
||||
<Route path="units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="settings" element={<SuperAdminSettingsPage />} />
|
||||
<Route path="platform-settings" element={<SuperAdminPlatformSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Tenant-роуты — обычный AppLayout, но с TenantRouteGuard:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ const NAV: NavSection[] = [
|
|||
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
|
||||
{ to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true },
|
||||
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' },
|
||||
{ to: '/super-admin/platform-settings', icon: Settings, label: 'SMTP / Email' },
|
||||
]},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -96,10 +96,6 @@ export function LoginPage() {
|
|||
{loading ? 'Выполняется вход…' : 'Войти'}
|
||||
</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">
|
||||
Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue