- EmailTemplates (invite/low-stock/weekly-summary): шапка с текстовым вордмарком «all market» (бренд-цвета, всегда рендерится — без внешних картинок и привязки к домену) + подпись «ALL MARKET — система учёта торговли». - Имя отправителя по умолчанию, темы/тела системных писем, экспорт-README, дневная сводка, тест SMTP → ALL MARKET. - Цвета писем и домены не менялись. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
6.7 KiB
C#
163 lines
6.7 KiB
C#
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) ? "ALL MARKET — тестовое сообщение" : input.Subject,
|
||
string.IsNullOrWhiteSpace(input.Body) ? "Тестовое письмо от ALL 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);
|
||
}
|
||
}
|