From a5fd034a316f699be2385d91e00a2a8f3ee4b1bf Mon Sep 17 00:00:00 2001 From: David Headrick Date: Fri, 21 Mar 2025 07:38:46 -0500 Subject: [PATCH] Add mailing functionality and improve authentication - Implemented Logout and RefreshToken methods in AuthenticationController. - Added IMailingService and IMailingRepository to Program.cs. - Updated project structure in Surge365.MassEmailReact.API.csproj. - Modified API host address and endpoints in Server.http. - Introduced AuthAppCode in appsettings.json for context distinction. - Changed GenerateTokens method to async in IAuthService. - Initialized string properties in User.cs to avoid null values. - Added new Mailing mapping in DapperConfiguration.cs. - Created MailingsController for handling mailing operations. - Developed Mailing, MailingUpdateDto, IMailingService, and IMailingRepository classes. - Updated frontend with MailingEdit and NewMailings components. - Enhanced authentication handling in AuthCheck.tsx and AuthContext.tsx. - Introduced ProtectedPageWrapper for route protection based on roles. - Added EmailList component for email input validation. - Updated utils.ts for token and cookie management functions. - Modified vite.config.ts for new HTTPS certificate name. - Updated CHANGELOG.md to reflect recent changes. --- .../Controllers/AuthenticationController.cs | 50 ++- .../Controllers/MailingsController.cs | 81 +++++ Surge365.MassEmailReact.API/Program.cs | 2 + .../Surge365.MassEmailReact.API.csproj | 2 +- .../Surge365.MassEmailReact.Server.http | 21 +- Surge365.MassEmailReact.API/appsettings.json | 1 + .../DTOs/MailingUpdateDto.cs | 19 + .../Interfaces/IAuthService.cs | 2 +- .../Interfaces/IMailingRepository.cs | 17 + .../Interfaces/IMailingService.cs | 19 + .../Interfaces/IUserRepository.cs | 4 + .../Entities/Mailing.cs | 50 +++ .../Entities/User.cs | 39 +- .../Enums/MailingStatus.cs | 65 ++++ .../DapperMaps/DapperConfiguration.cs | 7 +- .../DapperMaps/JsonListStringTypeHandler.cs | 30 ++ .../DapperMaps/MailingMap.cs | 25 ++ .../DapperMaps/UserMap.cs | 25 ++ .../DataAccess.cs | 8 +- .../Repositories/MailingRepository.cs | 127 +++++++ .../Repositories/UserRepository.cs | 170 ++++----- .../Services/AuthService.cs | 40 +- .../Services/MailingService.cs | 84 +++++ .../Utilities.cs | 19 + Surge365.MassEmailReact.Web/CHANGELOG.md | 4 +- Surge365.MassEmailReact.Web/package-lock.json | 91 ++++- Surge365.MassEmailReact.Web/package.json | 3 +- .../src/components/auth/AuthCheck.tsx | 55 +++ .../src/components/auth/AuthContext.tsx | 40 ++ .../components/auth/ProtectedPageWrapper.tsx | 71 ++++ .../src/components/forms/EmailList.tsx | 54 +++ .../src/components/layouts/Layout.tsx | 118 +++++- .../src/components/modals/MailingEdit.tsx | 343 ++++++++++++++++++ .../src/components/pages/App.tsx | 273 ++++++++------ .../src/components/pages/AppMain.tsx | 66 +--- .../src/components/pages/Login.tsx | 18 +- .../src/components/pages/NewMailings.tsx | 167 +++++++++ Surge365.MassEmailReact.Web/src/ts/utils.ts | 26 +- .../src/types/mailing.ts | 15 + Surge365.MassEmailReact.Web/vite.config.ts | 2 +- 40 files changed, 1899 insertions(+), 354 deletions(-) create mode 100644 Surge365.MassEmailReact.API/Controllers/MailingsController.cs create mode 100644 Surge365.MassEmailReact.Application/DTOs/MailingUpdateDto.cs create mode 100644 Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs create mode 100644 Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs create mode 100644 Surge365.MassEmailReact.Domain/Entities/Mailing.cs create mode 100644 Surge365.MassEmailReact.Domain/Enums/MailingStatus.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/DapperMaps/JsonListStringTypeHandler.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingMap.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/DapperMaps/UserMap.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Utilities.cs create mode 100644 Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx create mode 100644 Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx create mode 100644 Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx create mode 100644 Surge365.MassEmailReact.Web/src/components/forms/EmailList.tsx create mode 100644 Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx create mode 100644 Surge365.MassEmailReact.Web/src/components/pages/NewMailings.tsx create mode 100644 Surge365.MassEmailReact.Web/src/types/mailing.ts diff --git a/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs b/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs index b825bc5..1568326 100644 --- a/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs +++ b/Surge365.MassEmailReact.API/Controllers/AuthenticationController.cs @@ -15,6 +15,20 @@ namespace Surge365.MassEmailReact.API.Controllers _authService = authService; } + [HttpPost("logout")] + public IActionResult Logout() + { + Response.Cookies.Append("refreshToken", "", new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(-1) // Expire immediately + }); + + return Ok(new { message = "Logged out successfully" }); + } + [HttpPost("authenticate")] public async Task Authenticate([FromBody] LoginRequest request) { @@ -24,23 +38,45 @@ namespace Surge365.MassEmailReact.API.Controllers else if(authResponse.data == null) return Unauthorized(new { message = "Invalid credentials" }); + var cookieOptions = new CookieOptions + { + HttpOnly = true, // Prevents JavaScript access (mitigates XSS) + Secure = true, // Ensures cookie is only sent over HTTPS + SameSite = SameSiteMode.Strict, // Mitigates CSRF by restricting cross-site usage + Expires = DateTimeOffset.UtcNow.AddDays(7) + }; + Response.Cookies.Append("refreshToken", authResponse.data.Value.refreshToken, cookieOptions); + //TODO: Store user in session - return Ok(new { success = true, authResponse.data.Value.accessToken, authResponse.data.Value.user }); //TODO: Send refresh token in http only cookie. + return Ok(new { success = true, authResponse.data.Value.accessToken, authResponse.data.Value.user }); } [HttpPost("refreshtoken")] - public IActionResult RefreshToken([FromBody] RefreshTokenRequest request) + public async Task RefreshToken() { - Guid? userId = Guid.NewGuid();//TODO: Lookup user in session + var refreshToken = Request.Cookies["refreshToken"]; + + if (string.IsNullOrWhiteSpace(refreshToken)) + return Unauthorized("Invalid refresh token"); + + Guid? userId = Guid.Parse("B077E02E-7383-4942-B57D-F2DFA9D33B8E");//TODO: Lookup user in session by refresh token if (userId == null) { return Unauthorized("Invalid refresh token"); } - var tokens = _authService.GenerateTokens(userId.Value, request.RefreshToken); - if(tokens == null) - return Unauthorized(); + var tokens = await _authService.GenerateTokens(userId.Value, refreshToken); + if(tokens == null) + return Unauthorized("Invalid refresh token"); + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(7) + }; + Response.Cookies.Append("refreshToken", tokens.Value.refreshToken, cookieOptions); - return Ok(new { accessToken = tokens.Value.accessToken, refreshToken = tokens.Value.refreshToken }); + return Ok(new { accessToken = tokens.Value.accessToken }); } [HttpPost("generatepasswordrecovery")] public IActionResult GeneratePasswordRecovery([FromBody] GeneratePasswordRecoveryRequest request) diff --git a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs new file mode 100644 index 0000000..fefc75f --- /dev/null +++ b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using Surge365.MassEmailReact.Application.Interfaces; +using Surge365.MassEmailReact.Domain.Entities; +using Surge365.MassEmailReact.Domain.Enums; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.API.Controllers +{ + public class MailingsController : BaseController + { + private readonly IMailingService _mailingService; + + public MailingsController(IMailingService mailingService) + { + _mailingService = mailingService; + } + + //[HttpGet("new")] + //public async Task GetNew() + //{ + // var mailings = await _mailingService.GetByStatusAsync(BlastStatus.Editing.ToCode()); + // return Ok(mailings); + //} + + [HttpGet("available")] + public async Task CheckNameAvailable([FromQuery] int? id, [FromQuery][Required] string name) + { + var available = await _mailingService.NameIsAvailableAsync(id, name); + return Ok(available); + } + + [HttpGet("status/{statusCode}")] + public async Task GetByStatus(string statusCode) + { + var mailings = await _mailingService.GetByStatusAsync(statusCode); + return Ok(mailings); + } + + [HttpGet("{id}")] + public async Task GetById(int id) + { + var mailing = await _mailingService.GetByIdAsync(id); + return mailing is not null ? Ok(mailing) : NotFound($"Mailing with id '{id}' not found."); + } + + [HttpPost] + public async Task CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto) + { + if (mailingUpdateDto.Id != null && mailingUpdateDto.Id > 0) + return BadRequest("Id must be null or 0"); + + var mailingId = await _mailingService.CreateAsync(mailingUpdateDto); + if (mailingId == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create mailing."); + + var createdMailing = await _mailingService.GetByIdAsync(mailingId.Value); + return Ok(createdMailing); + } + + [HttpPut("{id}")] + public async Task UpdateMailing(int id, [FromBody] MailingUpdateDto mailingUpdateDto) + { + if (id != mailingUpdateDto.Id) + return BadRequest("Id in URL does not match Id in request body"); + + var existingMailing = await _mailingService.GetByIdAsync(id); + if (existingMailing == null) + return NotFound($"Mailing with Id {id} not found"); + + var success = await _mailingService.UpdateAsync(mailingUpdateDto); + if (!success) + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update mailing."); + + var updatedMailing = await _mailingService.GetByIdAsync(id); + return Ok(updatedMailing); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.API/Program.cs b/Surge365.MassEmailReact.API/Program.cs index 0142ece..fac2119 100644 --- a/Surge365.MassEmailReact.API/Program.cs +++ b/Surge365.MassEmailReact.API/Program.cs @@ -28,6 +28,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Surge365.MassEmailReact.API/Surge365.MassEmailReact.API.csproj b/Surge365.MassEmailReact.API/Surge365.MassEmailReact.API.csproj index 0a18f36..ac6f37b 100644 --- a/Surge365.MassEmailReact.API/Surge365.MassEmailReact.API.csproj +++ b/Surge365.MassEmailReact.API/Surge365.MassEmailReact.API.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - ..\surge365.massemailreact.client + ..\Surge365.MassEmailReact.Web npm run dev https://localhost:52871 diff --git a/Surge365.MassEmailReact.API/Surge365.MassEmailReact.Server.http b/Surge365.MassEmailReact.API/Surge365.MassEmailReact.Server.http index 5df96be..444d9e6 100644 --- a/Surge365.MassEmailReact.API/Surge365.MassEmailReact.Server.http +++ b/Surge365.MassEmailReact.API/Surge365.MassEmailReact.Server.http @@ -1,11 +1,28 @@ -@Surge365.MassEmailReact.API_HostAddress = http://localhost:5065/api +@Surge365.MassEmailReact.Local_HostAddress = http://localhost:5065/api @Surge365.MassEmailReact.UATServer_HostAddress = https://uat.massemail2.surge365.com/api +@Surge365.MassEmailReact.API_HostAddress = http://localhost:5065/api + +# Step 1: Authenticate to get the refresh token cookie +POST {{Surge365.MassEmailReact.API_HostAddress}}/authentication/authenticate +Content-Type: application/json +Accept: application/json +{ + "username": "dheadrick", + "password": "Password1" +} +### +# Step 2: Call refreshtoken with the cookie +POST {{Surge365.MassEmailReact.API_HostAddress}}/authentication/refreshtoken +Accept: application/json +Cookie: refreshToken=hhlLpqHP0kiYhyyBDr9hZw== +### + GET {{Surge365.MassEmailReact.API_HostAddress}}/servers/ Accept: application/json ### -GET {{Surge365.MassEmailReact.UATServer_HostAddress}}/servers/get +GET {{Surge365.MassEmailReact.API_HostAddress}}/servers/get Accept: application/json ### diff --git a/Surge365.MassEmailReact.API/appsettings.json b/Surge365.MassEmailReact.API/appsettings.json index ca15a90..2936fe7 100644 --- a/Surge365.MassEmailReact.API/appsettings.json +++ b/Surge365.MassEmailReact.API/appsettings.json @@ -10,6 +10,7 @@ "Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d" }, "AppCode": "MassEmailReactApi", + "AuthAppCode": "MassEmailWeb", "EnvironmentCode": "UAT", "ConnectionStrings": { "Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT diff --git a/Surge365.MassEmailReact.Application/DTOs/MailingUpdateDto.cs b/Surge365.MassEmailReact.Application/DTOs/MailingUpdateDto.cs new file mode 100644 index 0000000..6ab7804 --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/MailingUpdateDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace Surge365.MassEmailReact.Domain.Entities +{ + public class MailingUpdateDto + { + public int? Id { get; set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int TemplateId { get; set; } + public int TargetId { get; set; } + public string StatusCode { get; set; } = ""; + public DateTime? ScheduleDate { get; set; } + public DateTime? SentDate { get; set; } + public Guid? SessionActivityId { get; set; } + public string? RecurringTypeCode { get; set; } + public DateTime? RecurringStartDate { get; set; } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs b/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs index 1ef1557..41b0618 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IAuthService.cs @@ -5,6 +5,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces public interface IAuthService { Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password); - (string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken); + Task<(string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string refreshToken); } } diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs new file mode 100644 index 0000000..b81a833 --- /dev/null +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs @@ -0,0 +1,17 @@ +using Surge365.MassEmailReact.Domain.Entities; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Application.Interfaces +{ + public interface IMailingRepository + { + Task GetByIdAsync(int id); + Task> GetAllAsync(bool activeOnly = true); + Task> GetByStatusAsync(string code); + Task NameIsAvailableAsync(int? id, string name); + + Task CreateAsync(Mailing mailing); + Task UpdateAsync(Mailing mailing); + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs new file mode 100644 index 0000000..0262864 --- /dev/null +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs @@ -0,0 +1,19 @@ +using Surge365.MassEmailReact.Domain.Entities; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Application.Interfaces +{ + public interface IMailingService + { + Task GetByIdAsync(int id); + Task> GetAllAsync(bool activeOnly = true); + + Task> GetByStatusAsync(string code); + Task NameIsAvailableAsync(int? id, string name); + + Task CreateAsync(MailingUpdateDto mailingDto); + Task UpdateAsync(MailingUpdateDto mailingDto); + + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs index 4f9f46f..5a51f89 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IUserRepository.cs @@ -11,5 +11,9 @@ namespace Surge365.MassEmailReact.Application.Interfaces { Task<(User? user, string message)> Authenticate(string username, string password); bool Authenticate(Guid userId, string refreshToken); + Task GetByUsername(string username); + Task GetByKey(int userKey); + Task GetById(Guid userId); + Task> GetAll(bool activeOnly = true); } } diff --git a/Surge365.MassEmailReact.Domain/Entities/Mailing.cs b/Surge365.MassEmailReact.Domain/Entities/Mailing.cs new file mode 100644 index 0000000..a92bb2a --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Entities/Mailing.cs @@ -0,0 +1,50 @@ +using System; + +namespace Surge365.MassEmailReact.Domain.Entities +{ + public class Mailing + { + public int? Id { get; private set; } + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int TemplateId { get; set; } + public int TargetId { get; set; } + public string StatusCode { get; set; } = ""; + public DateTime? ScheduleDate { get; set; } + public DateTime? SentDate { get; set; } + public DateTime CreateDate { get; set; } = DateTime.Now; + public DateTime UpdateDate { get; set; } = DateTime.Now; + public Guid? SessionActivityId { get; set; } + public string? RecurringTypeCode { get; set; } + public DateTime? RecurringStartDate { get; set; } + + public Mailing() { } + + private Mailing(int id, string name, string description, int templateId, int targetId, + string statusCode, DateTime? scheduleDate, DateTime? sentDate, DateTime createDate, + DateTime updateDate, Guid? sessionActivityId, string? recurringTypeCode, DateTime? recurringStartDate) + { + Id = id; + Name = name; + Description = description; + TemplateId = templateId; + TargetId = targetId; + StatusCode = statusCode; + ScheduleDate = scheduleDate; + SentDate = sentDate; + CreateDate = createDate; + UpdateDate = updateDate; + SessionActivityId = sessionActivityId; + RecurringTypeCode = recurringTypeCode; + RecurringStartDate = recurringStartDate; + } + + public static Mailing Create(int id, string name, string description, int templateId, int targetId, + string statusCode, DateTime? scheduleDate, DateTime? sentDate, DateTime createDate, + DateTime updateDate, Guid? sessionActivityId, string? recurringTypeCode, DateTime? recurringStartDate) + { + return new Mailing(id, name, description, templateId, targetId, statusCode, scheduleDate, + sentDate, createDate, updateDate, sessionActivityId, recurringTypeCode, recurringStartDate); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Domain/Entities/User.cs b/Surge365.MassEmailReact.Domain/Entities/User.cs index 7e7b7fe..b2f7f9c 100644 --- a/Surge365.MassEmailReact.Domain/Entities/User.cs +++ b/Surge365.MassEmailReact.Domain/Entities/User.cs @@ -10,25 +10,28 @@ namespace Surge365.MassEmailReact.Domain.Entities { public int? UserKey { get; private set; } public Guid UserId { get; private set; } - public string Username { get; private set; } - public string FirstName { get; private set; } - public string MiddleInitial { get; private set; } - public string LastName { get; private set; } + public string Username { get; private set; } = ""; + public string FirstName { get; private set; } = ""; + public string MiddleInitial { get; private set; } = ""; + public string LastName { get; private set; } = ""; public bool IsActive { get; private set; } + public List Roles { get; private set; } = new List(); - private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive) - { - UserKey = userKey; - UserId = userId; - Username = username; - FirstName = firstName ?? ""; - MiddleInitial = middleInitial ?? ""; - LastName = lastName ?? ""; - IsActive = isActive; - } - public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive) - { - return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive); - } + public User() { } + //private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List roles) + //{ + // UserKey = userKey; + // UserId = userId; + // Username = username; + // FirstName = firstName ?? ""; + // MiddleInitial = middleInitial ?? ""; + // LastName = lastName ?? ""; + // IsActive = isActive; + // Roles = roles; + //} + //public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List roles) + //{ + // return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive, roles); + //} } } diff --git a/Surge365.MassEmailReact.Domain/Enums/MailingStatus.cs b/Surge365.MassEmailReact.Domain/Enums/MailingStatus.cs new file mode 100644 index 0000000..2264b8e --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Enums/MailingStatus.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Domain.Enums +{ + public enum MailingStatus + { + Cancelled , + Editing, + Error, + QueueingError, + Sent, + Scheduled, + Sending + } + + public static class MailingStatusExtensions + { + public static MailingStatus FromCode(string code) + { + return code.ToUpper().Trim() switch + { + "C" => MailingStatus.Cancelled, + "ED" => MailingStatus.Editing, + "ER" => MailingStatus.Error, + "QE" => MailingStatus.QueueingError, + "S" => MailingStatus.Sent, + "SC" => MailingStatus.Scheduled, + "SD" => MailingStatus.Sending, + _ => throw new ArgumentOutOfRangeException("code", code, null) + }; + } + public static string ToCode(this MailingStatus status) + { + return status switch + { + MailingStatus.Cancelled => "C", + MailingStatus.Editing => "ED", + MailingStatus.Error => "ER", + MailingStatus.QueueingError => "QE", + MailingStatus.Sent => "S", + MailingStatus.Scheduled => "SC", + MailingStatus.Sending => "SD", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + } + public static string ToFriendlyName(this MailingStatus status) + { + return status switch + { + MailingStatus.Cancelled => "Cancelled", + MailingStatus.Editing => "Editing", + MailingStatus.Error => "Error", + MailingStatus.QueueingError => "Queueing Error", + MailingStatus.Sent => "Sent", + MailingStatus.Scheduled => "Scheduled", + MailingStatus.Sending => "Sending", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs index 36e1a21..c07fe62 100644 --- a/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs @@ -1,4 +1,5 @@ -using Dapper.FluentMap; +using Dapper; +using Dapper.FluentMap; using Surge365.MassEmailReact.Domain.Entities; using System; using System.Collections.Generic; @@ -12,6 +13,8 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps { public static void ConfigureMappings() { + SqlMapper.AddTypeHandler(new JsonListStringTypeHandler()); + FluentMapper.Initialize(config => { config.AddMap(new TargetMap()); @@ -21,6 +24,8 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps config.AddMap(new UnsubscribeUrlMap()); config.AddMap(new TemplateMap()); config.AddMap(new EmailDomainMap()); + config.AddMap(new MailingMap()); + config.AddMap(new UserMap()); }); } } diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/JsonListStringTypeHandler.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/JsonListStringTypeHandler.cs new file mode 100644 index 0000000..de6f86a --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/JsonListStringTypeHandler.cs @@ -0,0 +1,30 @@ +using Dapper; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Infrastructure.DapperMaps +{ + public class JsonListStringTypeHandler : SqlMapper.TypeHandler> + { + public override List Parse(object value) + { + if (value == null || value == DBNull.Value) + return new List(); + + string json = value.ToString() ?? ""; + return string.IsNullOrEmpty(json) + ? new List() + : JsonSerializer.Deserialize>(json) ?? new List(); + } + + public override void SetValue(IDbDataParameter parameter, List value) + { + parameter.Value = value != null ? JsonSerializer.Serialize(value) : DBNull.Value; + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingMap.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingMap.cs new file mode 100644 index 0000000..0a9a26c --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingMap.cs @@ -0,0 +1,25 @@ +using Dapper.FluentMap.Mapping; +using Surge365.MassEmailReact.Domain.Entities; + +namespace Surge365.MassEmailReact.Infrastructure.DapperMaps +{ + public class MailingMap : EntityMap + { + public MailingMap() + { + Map(m => m.Id).ToColumn("blast_key"); + Map(m => m.Name).ToColumn("name"); + Map(m => m.Description).ToColumn("description"); + Map(m => m.TemplateId).ToColumn("template_key"); + Map(m => m.TargetId).ToColumn("target_key"); + Map(m => m.StatusCode).ToColumn("blast_status_code"); + Map(m => m.ScheduleDate).ToColumn("schedule_date"); + Map(m => m.SentDate).ToColumn("sent_date"); + Map(m => m.CreateDate).ToColumn("create_date"); + Map(m => m.UpdateDate).ToColumn("update_date"); + Map(m => m.SessionActivityId).ToColumn("session_activity_id"); + Map(m => m.RecurringTypeCode).ToColumn("blast_recurring_type_code"); + Map(m => m.RecurringStartDate).ToColumn("recurring_start_date"); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/UserMap.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/UserMap.cs new file mode 100644 index 0000000..79620e8 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/UserMap.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Dapper.FluentMap.Mapping; +using Surge365.MassEmailReact.Domain.Entities; + +namespace Surge365.MassEmailReact.Infrastructure.DapperMaps +{ + public class UserMap : EntityMap + { + public UserMap() + { + Map(u => u.UserKey).ToColumn("login_key"); + Map(u => u.UserId).ToColumn("session_activity_id"); // Assuming this is the Guid mapping + Map(u => u.Username).ToColumn("username"); + Map(u => u.FirstName).ToColumn("first_name"); + Map(u => u.MiddleInitial).ToColumn("middle_initial"); + Map(u => u.LastName).ToColumn("last_name"); + Map(u => u.IsActive).ToColumn("is_active"); + Map(u => u.Roles).ToColumn("roles"); + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/DataAccess.cs b/Surge365.MassEmailReact.Infrastructure/DataAccess.cs index f79b5ae..f9947c1 100644 --- a/Surge365.MassEmailReact.Infrastructure/DataAccess.cs +++ b/Surge365.MassEmailReact.Infrastructure/DataAccess.cs @@ -20,16 +20,10 @@ namespace Surge365.MassEmailReact.Infrastructure return ""; return _configuration[$"ConnectionStrings:{connectionStringName}"] ?? ""; } - private string GetAppCode() - { - if (_configuration == null) - return ""; - return _configuration[$"AppCode"] ?? ""; - } public DataAccess(IConfiguration configuration, string connectionStringName) { _configuration = configuration; - _connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", GetAppCode()); + _connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", Utilities.GetAppCode(configuration)); } internal IConfiguration? _configuration; diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs new file mode 100644 index 0000000..df0444a --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs @@ -0,0 +1,127 @@ +using Dapper; +using Dapper.FluentMap; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Application.Interfaces; +using Surge365.MassEmailReact.Domain.Entities; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Infrastructure.Repositories +{ + public class MailingRepository : IMailingRepository + { + private readonly IConfiguration _config; + private const string _connectionStringName = "MassEmail.ConnectionString"; + private string? ConnectionString => _config.GetConnectionString(_connectionStringName); + + public MailingRepository(IConfiguration config) + { + _config = config; +#if DEBUG + if (!FluentMapper.EntityMaps.ContainsKey(typeof(Mailing))) + { + throw new InvalidOperationException("Mailing dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup)."); + } +#endif + } + + public async Task GetByIdAsync(int id) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + + using SqlConnection conn = new SqlConnection(ConnectionString); + return (await conn.QueryAsync("mem_get_blast_by_id", new { blast_key = id }, commandType: CommandType.StoredProcedure)).FirstOrDefault(); + } + + public async Task> GetAllAsync(bool activeOnly = true) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + + using SqlConnection conn = new SqlConnection(ConnectionString); + return (await conn.QueryAsync("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList(); + } + + public async Task> GetByStatusAsync(string code) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + + using SqlConnection conn = new SqlConnection(ConnectionString); + return (await conn.QueryAsync("mem_get_blast_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); + } + public async Task NameIsAvailableAsync(int? id, string name) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + using var conn = new SqlConnection(ConnectionString); + + var parameters = new DynamicParameters(); + parameters.Add("@blast_key", id, DbType.Int32); + parameters.Add("@blast_name", name, DbType.String); + parameters.Add("@available", dbType: DbType.Boolean, direction: ParameterDirection.Output); + + await conn.ExecuteAsync("mem_is_blast_name_available", parameters, commandType: CommandType.StoredProcedure); + + return parameters.Get("@available"); + } + + + public async Task CreateAsync(Mailing mailing) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + ArgumentNullException.ThrowIfNull(mailing); + if (mailing.Id != null && mailing.Id > 0) + throw new Exception("ID must be null"); + + using SqlConnection conn = new SqlConnection(ConnectionString); + var parameters = new DynamicParameters(); + parameters.Add("@blast_key", dbType: DbType.Int32, direction: ParameterDirection.Output); + parameters.Add("@name", mailing.Name, DbType.String); + parameters.Add("@description", mailing.Description, DbType.String); + parameters.Add("@template_key", mailing.TemplateId, DbType.Int32); + parameters.Add("@target_key", mailing.TargetId, DbType.Int32); + parameters.Add("@blast_status_code", mailing.StatusCode, DbType.String); + parameters.Add("@schedule_date", mailing.ScheduleDate, DbType.DateTime); + parameters.Add("@sent_date", mailing.SentDate, DbType.DateTime); + parameters.Add("@session_activity_id", mailing.SessionActivityId, DbType.Guid); + parameters.Add("@blast_recurring_type_code", mailing.RecurringTypeCode, DbType.String); + parameters.Add("@recurring_start_date", mailing.RecurringStartDate, DbType.DateTime); + parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output); + + await conn.ExecuteAsync("mem_save_blast", parameters, commandType: CommandType.StoredProcedure); + + bool success = parameters.Get("@success"); + if (success) + return parameters.Get("@blast_key"); + return null; + } + + public async Task UpdateAsync(Mailing mailing) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + ArgumentNullException.ThrowIfNull(mailing); + ArgumentNullException.ThrowIfNull(mailing.Id); + + using SqlConnection conn = new SqlConnection(ConnectionString); + var parameters = new DynamicParameters(); + parameters.Add("@blast_key", mailing.Id, DbType.Int32); + parameters.Add("@name", mailing.Name, DbType.String); + parameters.Add("@description", mailing.Description, DbType.String); + parameters.Add("@template_key", mailing.TemplateId, DbType.Int32); + parameters.Add("@target_key", mailing.TargetId, DbType.Int32); + parameters.Add("@blast_status_code", mailing.StatusCode, DbType.String); + parameters.Add("@schedule_date", mailing.ScheduleDate, DbType.DateTime); + parameters.Add("@sent_date", mailing.SentDate, DbType.DateTime); + parameters.Add("@session_activity_id", mailing.SessionActivityId, DbType.Guid); + parameters.Add("@blast_recurring_type_code", mailing.RecurringTypeCode, DbType.String); + parameters.Add("@recurring_start_date", mailing.RecurringStartDate, DbType.DateTime); + parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output); + + await conn.ExecuteAsync("mem_save_blast", parameters, commandType: CommandType.StoredProcedure); + + return parameters.Get("@success"); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs index 18bd8e7..290342e 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs @@ -1,4 +1,5 @@ -using Microsoft.Data.SqlClient; +using Dapper; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Domain.Entities; @@ -7,134 +8,107 @@ using Surge365.MassEmailReact.Domain.Enums.Extensions; using System; using System.Collections.Generic; using System.Data; -using System.Linq; -using System.Text; using System.Threading.Tasks; namespace Surge365.MassEmailReact.Infrastructure.Repositories { - public class UserRepository (IConfiguration config) : IUserRepository + public class UserRepository : IUserRepository { - private IConfiguration _config = config; + private readonly IConfiguration _config; private const string _connectionStringName = "Marketing.ConnectionString"; - //private static readonly List Users = new(); + public UserRepository(IConfiguration config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public string AppCode + { + get + { + return _config?["AuthAppCode"] ?? Utilities.GetAppCode(_config); + } + } + + private string ConnectionString => _config.GetConnectionString(_connectionStringName) ?? ""; public async Task<(User? user, string message)> Authenticate(string username, string password) { - List pms = new List(); - pms.Add(new SqlParameter("username", username)); - pms.Add(new SqlParameter("password", password)); - pms.Add(new SqlParameter("application_code", "MassEmailWeb")); //TODO: Pull from config + using var connection = new SqlConnection(ConnectionString); + var parameters = new DynamicParameters(); + parameters.Add("username", username); + parameters.Add("password", password); + parameters.Add("application_code", AppCode); + parameters.Add("response_number", dbType: DbType.Int16, direction: ParameterDirection.Output); + parameters.Add("login_key", dbType: DbType.Int32, direction: ParameterDirection.Output); - SqlParameter pmResponseNumber = new SqlParameter("response_number", SqlDbType.SmallInt); - pmResponseNumber.Direction = ParameterDirection.Output; - pms.Add(pmResponseNumber); + var result = await connection.QueryAsync( + "adm_authenticate_login", + parameters, + commandType: CommandType.StoredProcedure + ); - SqlParameter pUserKey = new SqlParameter("login_key", SqlDbType.Int); - pUserKey.Direction = ParameterDirection.Output; - pms.Add(pUserKey); + var responseNumber = parameters.Get("response_number"); + var authResult = (AuthResult)responseNumber; + string responseMessage = authResult.GetMessage(); - DataAccess da = new DataAccess(_config, _connectionStringName); - DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_authenticate_login"); - - var result = (AuthResult)Convert.ToInt16(pmResponseNumber.Value); - - string responseMessage = AuthResultExtensions.GetMessage(result); - if (result == AuthResult.Success) + if (authResult == AuthResult.Success) { - if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) - return (null, "No user row returned"); - return (LoadFromDataRow(ds.Tables[0].Rows[0]), responseMessage); + var user = result.FirstOrDefault(); + return (user, responseMessage); } return (null, responseMessage); } + public bool Authenticate(Guid userId, string refreshToken) { - //TODO: Validate refresh token + // TODO: Validate refresh token return true; } + public async Task GetByUsername(string username) { - List pms = new List(); - pms.Add(new SqlParameter("username", username)); - - DataAccess da = new DataAccess(_config, _connectionStringName); - DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_login_by_username"); - if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) - return null; - - List users = LoadFromDataRow(ds.Tables[0]); - return users.FirstOrDefault(); + using var connection = new SqlConnection(ConnectionString); + var parameters = new { username, application_code = AppCode }; + return await connection.QueryFirstOrDefaultAsync( + "adm_get_login_by_username", + parameters, + commandType: CommandType.StoredProcedure + ); } + public async Task GetByKey(int userKey) { - List pms = new List(); - pms.Add(new SqlParameter("login_key", userKey)); - - DataAccess da = new DataAccess(_config, _connectionStringName); - DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_key"); - if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) - return null; - - List users = LoadFromDataRow(ds.Tables[0]); - return users.FirstOrDefault(); + using var connection = new SqlConnection(ConnectionString); + var parameters = new { login_key = userKey, application_code = AppCode }; + return await connection.QueryFirstOrDefaultAsync( + "adm_get_adm_login_by_key", + parameters, + commandType: CommandType.StoredProcedure + ); } + public async Task GetById(Guid userId) { - List pms = new List(); - pms.Add(new SqlParameter("login_id", userId)); - - DataAccess da = new DataAccess(_config, _connectionStringName); - DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_id"); - if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) - return null; - - List users = LoadFromDataRow(ds.Tables[0]); - return users.FirstOrDefault(); + using var connection = new SqlConnection(ConnectionString); + var parameters = new { login_id = userId, application_code = AppCode }; + return await connection.QueryFirstOrDefaultAsync( + "adm_get_adm_login_by_id", + parameters, + commandType: CommandType.StoredProcedure + ); } + public async Task> GetAll(bool activeOnly = true) { - List pms = new List(); - pms.Add(new SqlParameter("active_only", activeOnly)); - - DataAccess da = new DataAccess(_config, _connectionStringName); - DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all"); - if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) - throw new Exception("No users returned"); - - return LoadFromDataRow(ds.Tables[0]); - } - - - //public void Add(User user) - //{ - // Users.Add(user); - //} - - private List LoadFromDataRow(DataTable dt) - { - ArgumentNullException.ThrowIfNull(dt); - ArgumentNullException.ThrowIfNull(dt.Rows); - - List users = new List(); - foreach (DataRow dr in dt.Rows) - { - users.Add(LoadFromDataRow(dr)); - } - return users; - } - private User LoadFromDataRow(DataRow dr) - { - ArgumentNullException.ThrowIfNull(dr); - return User.Create(dr.Field("login_key"), - dr.Field("login_id"), - dr.Field("username")!, - dr.Field("first_name"), - dr.Field("middle_initial"), - dr.Field("last_name"), - dr.Field("is_active")); - + using var connection = new SqlConnection(ConnectionString); + var parameters = new { active_only = activeOnly, application_code = AppCode }; + var users = await connection.QueryAsync( + "adm_get_adm_login_all", + parameters, + commandType: CommandType.StoredProcedure + ); + return users.AsList(); } } -} +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs b/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs index 2f01f6e..faea461 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/AuthService.cs @@ -10,13 +10,14 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Configuration; using Surge365.MassEmailReact.Domain.Entities; using System.Security.Cryptography; +using System.Data; namespace Surge365.MassEmailReact.Infrastructure.Services { public class AuthService : IAuthService { - private const int TOKEN_MINUTES = 60; + private const int TOKEN_MINUTES = 5; private readonly IUserRepository _userRepository; private readonly IConfiguration _config; @@ -36,14 +37,16 @@ namespace Surge365.MassEmailReact.Infrastructure.Services // Generate JWT token var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!); + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()), + new Claim(JwtRegisteredClaimNames.UniqueName, username), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + claims.AddRange(authResponse.user.Roles.Select(role => new Claim(ClaimTypes.Role, role))); var tokenDescriptor = new SecurityTokenDescriptor { - Subject = new ClaimsIdentity(new[] - { - new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()), - new Claim(JwtRegisteredClaimNames.UniqueName, username), - new Claim(ClaimTypes.Role, "User") - }), + Subject = new ClaimsIdentity(claims), Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), SigningCredentials = new SigningCredentials( new SymmetricSecurityKey(key), @@ -58,24 +61,31 @@ namespace Surge365.MassEmailReact.Infrastructure.Services return (true, (authResponse.user, accessToken, refreshToken), ""); } - public (string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken) + public async Task<(string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string refreshToken) { if (!_userRepository.Authenticate(userId, refreshToken)) { return null; } + var user = await _userRepository.GetById(userId); + if (user == null) + { + return null; + } + var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!); - var username = ""; + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, user.UserId.ToString()), + new Claim(JwtRegisteredClaimNames.UniqueName, user.Username), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role))); //TODO: Look update User var tokenDescriptor = new SecurityTokenDescriptor { - Subject = new ClaimsIdentity(new[] - { - new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()!), - new Claim(JwtRegisteredClaimNames.UniqueName, username), - new Claim(ClaimTypes.Role, "User") - }), + Subject = new ClaimsIdentity(claims), Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), SigningCredentials = new SigningCredentials( new SymmetricSecurityKey(key), diff --git a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs new file mode 100644 index 0000000..137e7c3 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Application.Interfaces; +using Surge365.MassEmailReact.Domain.Entities; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Infrastructure.Services +{ + public class MailingService : IMailingService + { + private readonly IMailingRepository _mailingRepository; + private readonly IConfiguration _config; + + public MailingService(IMailingRepository mailingRepository, IConfiguration config) + { + _mailingRepository = mailingRepository; + _config = config; + } + + public async Task GetByIdAsync(int id) + { + return await _mailingRepository.GetByIdAsync(id); + } + + public async Task> GetAllAsync(bool activeOnly = true) + { + return await _mailingRepository.GetAllAsync(activeOnly); + } + public async Task> GetByStatusAsync(string statusCode) + { + return await _mailingRepository.GetByStatusAsync(statusCode); + } + public async Task NameIsAvailableAsync(int? id, string name) + { + return await _mailingRepository.NameIsAvailableAsync(id, name); + } + + public async Task CreateAsync(MailingUpdateDto mailingDto) + { + ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto)); + if (mailingDto.Id != null && mailingDto.Id > 0) + throw new Exception("ID must be null"); + + var mailing = new Mailing + { + Name = mailingDto.Name, + Description = mailingDto.Description, + TemplateId = mailingDto.TemplateId, + TargetId = mailingDto.TargetId, + StatusCode = mailingDto.StatusCode, + ScheduleDate = mailingDto.ScheduleDate, + SentDate = mailingDto.SentDate, + SessionActivityId = mailingDto.SessionActivityId, + RecurringTypeCode = mailingDto.RecurringTypeCode, + RecurringStartDate = mailingDto.RecurringStartDate + }; + + return await _mailingRepository.CreateAsync(mailing); + } + + public async Task UpdateAsync(MailingUpdateDto mailingDto) + { + ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto)); + ArgumentNullException.ThrowIfNull(mailingDto.Id, nameof(mailingDto.Id)); + + var mailing = await _mailingRepository.GetByIdAsync(mailingDto.Id.Value); + if (mailing == null || mailing.Id == null) return false; + + mailing.Name = mailingDto.Name; + mailing.Description = mailingDto.Description; + mailing.TemplateId = mailingDto.TemplateId; + mailing.TargetId = mailingDto.TargetId; + mailing.StatusCode = mailingDto.StatusCode; + mailing.ScheduleDate = mailingDto.ScheduleDate; + mailing.SentDate = mailingDto.SentDate; + mailing.SessionActivityId = mailingDto.SessionActivityId; + mailing.RecurringTypeCode = mailingDto.RecurringTypeCode; + mailing.RecurringStartDate = mailingDto.RecurringStartDate; + + return await _mailingRepository.UpdateAsync(mailing); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Utilities.cs b/Surge365.MassEmailReact.Infrastructure/Utilities.cs new file mode 100644 index 0000000..c76b1b8 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Utilities.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Surge365.MassEmailReact.Infrastructure +{ + public static class Utilities + { + public static string GetAppCode(IConfiguration? configuration) + { + if (configuration == null) + return ""; + return configuration["AppCode"] ?? ""; + } + } +} diff --git a/Surge365.MassEmailReact.Web/CHANGELOG.md b/Surge365.MassEmailReact.Web/CHANGELOG.md index a0e8c1b..3a2fb3e 100644 --- a/Surge365.MassEmailReact.Web/CHANGELOG.md +++ b/Surge365.MassEmailReact.Web/CHANGELOG.md @@ -4,11 +4,11 @@ The following tools were used to generate this project: - create-vite The following steps were used to generate this project: -- Create react project with create-vite: `npm init --yes vite@latest surge365.massemailreact.client -- --template=react-ts`. +- Create react project with create-vite: `npm init --yes vite@latest Surge365.MassEmailReact.Web -- --template=react-ts`. - Update `vite.config.ts` to set up proxying and certs. - Add `@type/node` for `vite.config.js` typing. - Update `App` component to fetch and display weather information. -- Create project file (`surge365.massemailreact.client.esproj`). +- Create project file (`Surge365.MassEmailReact.Web.esproj`). - Create `launch.json` to enable debugging. - Add project to solution. - Update proxy endpoint to be the backend server endpoint. diff --git a/Surge365.MassEmailReact.Web/package-lock.json b/Surge365.MassEmailReact.Web/package-lock.json index 3fe4b78..23feeba 100644 --- a/Surge365.MassEmailReact.Web/package-lock.json +++ b/Surge365.MassEmailReact.Web/package-lock.json @@ -1,11 +1,11 @@ { - "name": "surge365.massemailreact.client", + "name": "Surge365.MassEmailReact.Web", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "surge365.massemailreact.client", + "name": "Surge365.MassEmailReact.Web", "version": "0.0.0", "dependencies": { "@emotion/react": "^11.14.0", @@ -17,6 +17,7 @@ "@mui/material": "^6.4.5", "@mui/x-charts": "^7.27.1", "@mui/x-data-grid": "^7.27.1", + "@mui/x-date-pickers": "^7.28.0", "admin-lte": "4.0.0-beta3", "bootstrap": "^5.3.3", "dayjs": "^1.11.13", @@ -1612,6 +1613,92 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.28.0.tgz", + "integrity": "sha512-m1bfkZLOw3cMogeh6q92SjykVmLzfptnz3ZTgAlFKV7UBnVFuGUITvmwbgTZ1Mz3FmLVnGUQYUpZWw0ZnoghNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta", + "@mui/x-internals": "7.28.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/x-internals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.28.0.tgz", + "integrity": "sha512-p4GEp/09bLDumktdIMiw+OF4p+pJOOjTG0VUvzNxjbHB9GxbBKoMcHrmyrURqoBnQpWIeFnN/QAoLMFSpfwQbw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/x-internals": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", diff --git a/Surge365.MassEmailReact.Web/package.json b/Surge365.MassEmailReact.Web/package.json index eef6718..f1972c2 100644 --- a/Surge365.MassEmailReact.Web/package.json +++ b/Surge365.MassEmailReact.Web/package.json @@ -1,5 +1,5 @@ { - "name": "surge365.massemailreact.client", + "name": "Surge365.MassEmailReact.Web", "homepage": ".", "private": true, "version": "0.0.0", @@ -20,6 +20,7 @@ "@mui/material": "^6.4.5", "@mui/x-charts": "^7.27.1", "@mui/x-data-grid": "^7.27.1", + "@mui/x-date-pickers": "^7.28.0", "admin-lte": "4.0.0-beta3", "bootstrap": "^5.3.3", "dayjs": "^1.11.13", diff --git a/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx b/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx new file mode 100644 index 0000000..6d36a87 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx @@ -0,0 +1,55 @@ +// src/components/auth/AuthCheck.tsx +import React, { useEffect } from "react"; +import { useNavigate, useLocation } from 'react-router-dom'; +import utils from '@/ts/utils'; +import { useAuth } from '@/components/auth/AuthContext'; // Import useAuth + +const AuthCheck: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { accessToken, setAuth } = useAuth(); // Use context + + useEffect(() => { + const checkAuthStatus = async () => { + const currentPath = location.pathname; + if (currentPath.toLowerCase() === "/login") + return; + + if (!accessToken) { + await tryRefreshToken(); + } else { + if (utils.isTokenExpired(accessToken)) { + await tryRefreshToken(); + } else { + // Do nothing, token is still valid + } + } + }; + + const tryRefreshToken = async () => { + try { + const response = await fetch('/api/authentication/refreshtoken', { + method: 'POST', + credentials: 'include', + }); + if (response.ok) { + const data = await response.json(); + setAuth(data.accessToken); // Update context instead of localStorage directly + // DO NOT NAVIGATE TO LOGIN PAGE + } else { + setAuth(null); // Clear context on failure + navigate('/login'); + } + } catch { + setAuth(null); + navigate('/login'); + } + }; + + checkAuthStatus(); + }, [navigate, location.pathname, accessToken, setAuth]); // Add accessToken and setAuth to deps + + return null; +}; + +export default AuthCheck; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx b/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx new file mode 100644 index 0000000..702c1f6 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx @@ -0,0 +1,40 @@ +// src/components/auth/AuthContext.tsx +import { createContext, useContext, useState, ReactNode } from 'react'; +import utils from '@/ts/utils'; + +interface AuthContextType { + accessToken: string | null; + userRoles: string[]; + setAuth: (token: string | null) => void; +} + +const AuthContext = createContext({ + accessToken: null, + userRoles: [], + setAuth: () => { }, +}); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [accessToken, setAccessToken] = useState(localStorage.getItem('accessToken')); + const [userRoles, setUserRoles] = useState(accessToken ? utils.getUserRoles(accessToken) : []); + + const setAuth = (token: string | null) => { + if (token) { + localStorage.setItem('accessToken', token); + setAccessToken(token); + setUserRoles(utils.getUserRoles(token)); + } else { + localStorage.removeItem('accessToken'); + setAccessToken(null); + setUserRoles([]); + } + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => useContext(AuthContext); \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx b/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx new file mode 100644 index 0000000..307c3ec --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx @@ -0,0 +1,71 @@ +// src/components/auth/ProtectedPageWrapper.tsx +import React, { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTitle } from "@/context/TitleContext"; +import utils from '@/ts/utils'; + +// Define role requirements for routes +export const routeRoleRequirements: Record = { + '/home': [], // No role required + '/servers': ['ServerTab'], // Only Admins + '/targets': ['TargetTab'], // Users or Admins + '/testEmailLists': ['TestListTab'], + '/blockedEmails': ['BlockedEmailTab'], + '/emailDomains': ['DomainTab'], + '/unsubscribeUrls': ['UnsubscribeUrlTab'], + '/templates': ['TemplateTab'], + '/newMailings': ['NewMailingTab'], + '/scheduledMailings': ['ScheduledMailingTab'], + '/activeMailings': ['ActiveMailingTab'], + '/completedMailings': ['CompletedMailingTab'], +}; + +const ProtectedPageWrapper: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => { + const navigate = useNavigate(); + const { setTitle } = useTitle(); + const accessToken = localStorage.getItem('accessToken'); + const currentPath = window.location.pathname; // Or use useLocation().pathname + + useEffect(() => { + setTitle(title); + }, [title, setTitle]); + + useEffect(() => { + const checkAuthAndRoles = async () => { + if (!accessToken || utils.isTokenExpired(accessToken)) { + try { + const response = await fetch('/api/authentication/refreshtoken', { + method: 'POST', + credentials: 'include', + }); + if (response.ok) { + const data = await response.json(); + localStorage.setItem('accessToken', data.accessToken); + } else { + navigate('/login'); + } + } catch { + navigate('/login'); + } + } else { + // Check roles + const userRoles = utils.getUserRoles(accessToken); + const requiredRoles = routeRoleRequirements[currentPath] || []; + const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => userRoles.includes(role)); + + if (!hasRequiredRole) { + navigate('/home'); // Redirect to home if unauthorized + } + } + }; + checkAuthAndRoles(); + }, [navigate, accessToken, currentPath]); + + if (!accessToken || utils.isTokenExpired(accessToken)) { + return null; // Or a loading spinner + } + + return <>{children}; +}; + +export default ProtectedPageWrapper; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/forms/EmailList.tsx b/Surge365.MassEmailReact.Web/src/components/forms/EmailList.tsx new file mode 100644 index 0000000..b0723db --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/forms/EmailList.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { TextField } from '@mui/material'; + +interface EmailListProps { + emails: string[], + onEmailTextChange: (emails: string[], invalidEmails: string[], error: string) => void; +} + +const EmailList: React.FC = ({ emails, onEmailTextChange }) => { + const [error, setError] = useState(''); + // Function to validate email format using a regular expression + const validateEmail = (email: string): boolean => { + const re = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]+$/; + return re.test(String(email).toLowerCase().trim()); + }; + + // Handle changes to the multiline text field + const handleChange = (e: React.ChangeEvent) => { + const value: string = e.target.value; + + // Split the input into individual lines + const emailLines: string[] = value.split('\n').filter((line) => line.trim() !== null); + + // Validate each line + const invalidEmails: string[] = emailLines.filter((email) => email.trim() !== '' && !validateEmail(email)); + + // Determine error message + const errorMessage = invalidEmails.length > 0 + ? `Invalid email(s): ${invalidEmails.join(', ')}` + : ''; + + setError(errorMessage); + // Call the parent's callback with the new value and error + onEmailTextChange(emailLines, invalidEmails, errorMessage); + }; + + const displayValue = emails.join('\n'); + + return ( + + ); +}; + +export default EmailList; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx b/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx index a61db02..0629389 100644 --- a/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx +++ b/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx @@ -1,8 +1,14 @@ // src/components/layouts/Layout.tsx + +import { useTitle } from "@/context/TitleContext"; +import { routeRoleRequirements } from '@/components/auth/ProtectedPageWrapper'; +import { useAuth } from '@/components/auth/AuthContext'; + import React, { ReactNode, useEffect } from 'react'; import { useTheme, useMediaQuery } from '@mui/material'; - +import { useNavigate } from 'react-router-dom'; import { styled, useColorScheme } from '@mui/material/styles'; + import Box from '@mui/material/Box'; import Drawer from '@mui/material/Drawer'; import AppBar from '@mui/material/AppBar'; @@ -19,6 +25,7 @@ import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import DashboardIcon from '@mui/icons-material/Dashboard'; import HttpIcon from '@mui/icons-material/Http'; +import AccountBoxIcon from '@mui/icons-material/AccountBox'; import DnsIcon from '@mui/icons-material/Dns'; import TargetIcon from '@mui/icons-material/TrackChanges'; @@ -33,11 +40,11 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { Link as RouterLink } from 'react-router-dom'; import Select, { SelectChangeEvent } from '@mui/material/Select'; +import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; -import { useTitle } from "@/context/TitleContext"; // Constants const drawerWidth = 240; @@ -77,6 +84,61 @@ const Layout = ({ children }: LayoutProps) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils? const { title } = useTitle(); + const navigate = useNavigate(); + + const menuItems = [ + { text: 'Home', icon: , path: '/home' }, + { text: 'Servers', icon: , path: '/servers' }, + { text: 'Targets', icon: , path: '/targets' }, + { text: 'Test Lists', icon: , path: '/testEmailLists' }, + { text: 'Blocked Emails', icon: , path: '/blockedEmails' }, + { text: 'Email Domains', icon: , path: '/emailDomains' }, + { text: 'Templates', icon: , path: '/templates' }, + { text: 'New Mailings', icon: , path: '/newMailings' }, + { text: 'Scheduled Mailings', icon: , path: '/scheduledMailings' }, + { text: 'Active Mailings', icon: , path: '/activeMailings' }, + { text: 'Completed Mailings', icon: , path: '/completedMailings' }, + ]; + const { userRoles, setAuth } = useAuth(); // Use context + const [profileMenuAnchorEl, setProfileMenuAnchorEl] = React.useState(null); + const profileMenuOpen = Boolean(profileMenuAnchorEl); + const handleOpenProfileMenu = (event: React.MouseEvent) => { + setProfileMenuAnchorEl(event.currentTarget); + }; + const handleCloseProfileMenu = () => { + setProfileMenuAnchorEl(null); + }; + + const visibleMenuItems = menuItems.filter(item => { + const requiredRoles = routeRoleRequirements[item.path] || []; + return requiredRoles.length == 0 || requiredRoles.some(role => userRoles.includes(role)); + }); + const handleRefreshUser = async () => { + handleCloseProfileMenu(); + try { + const response = await fetch('/api/authentication/refreshtoken', { + method: 'POST', + credentials: 'include', + + }); + if (response.ok) { + const data = await response.json(); + setAuth(data.accessToken); // Update context + } else { + setAuth(null); // Clear context on failure + navigate('/login'); + } + } catch { + setAuth(null); // Clear context on failure + navigate('/login'); + } + } + + const handleLogout = async () => { + setAuth(null); // Clear context + await fetch('/api/authentication/logout', { method: 'POST', credentials: 'include' }); + navigate('/login'); + }; const handleDrawerOpen = () => { setOpen(true); @@ -188,6 +250,29 @@ const Layout = ({ children }: LayoutProps) => { Dark + + + + + + Refresh User + Logout + + @@ -213,20 +298,21 @@ const Layout = ({ children }: LayoutProps) => { - {[ - { text: 'Home', icon: , path: '/home' }, - { text: 'Servers', icon: , path: '/servers' }, - { text: 'Targets', icon: , path: '/targets' }, - { text: 'Test Lists', icon: , path: '/testEmailLists' }, - { text: 'Blocked Emails', icon: , path: '/blockedEmails' }, - { text: 'Email Domains', icon: , path: '/emailDomains' }, - //{ text: 'Unsubscribe Urls', icon: , path: '/unsubscribeUrls' }, - { text: 'Templates', icon: , path: '/templates' }, - { text: 'New Mailings', icon: , path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard? - { text: 'Scheduled Mailings', icon: , path: '/scheduledMailings' }, // - { text: 'Active Mailings', icon: , path: '/activeMailings' }, - { text: 'Completed Mailings', icon: , path: '/completedMailings' }, - ].map((item) => ( + {/*{[*/} + {/* { text: 'Home', icon: , path: '/home' },*/} + {/* { text: 'Servers', icon: , path: '/servers' },*/} + {/* { text: 'Targets', icon: , path: '/targets' },*/} + {/* { text: 'Test Lists', icon: , path: '/testEmailLists' },*/} + {/* { text: 'Blocked Emails', icon: , path: '/blockedEmails' },*/} + {/* { text: 'Email Domains', icon: , path: '/emailDomains' },*/} + {/* //{ text: 'Unsubscribe Urls', icon: , path: '/unsubscribeUrls' },*/} + {/* { text: 'Templates', icon: , path: '/templates' },*/} + {/* { text: 'New Mailings', icon: , path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard?*/} + {/* { text: 'Scheduled Mailings', icon: , path: '/scheduledMailings' }, //*/} + {/* { text: 'Active Mailings', icon: , path: '/activeMailings' },*/} + {/* { text: 'Completed Mailings', icon: , path: '/completedMailings' },*/} + {/*].map((item) => (}*/} + {visibleMenuItems.map((item) => ( void; + onSave: (updatedMailing: Mailing) => void; +}; + +//const statusOptions = [ +// { code: 'C', name: 'Cancelled' }, +// { code: 'ED', name: 'Editing' }, +// { code: 'ER', name: 'Error' }, +// { code: 'QE', name: 'Queueing Error' }, +// { code: 'S', name: 'Sent' }, +// { code: 'SC', name: 'Scheduled' }, +// { code: 'SD', name: 'Sending' }, +//]; + +const recurringTypeOptions = [ + { code: 'D', name: 'Daily' }, + { code: 'M', name: 'Monthly' }, + { code: 'W', name: 'Weekly' }, +]; + +const schema = yup.object().shape({ + id: yup.number().nullable(), + name: yup.string().required("Name is required") + .test("unique-name", "Name must be unique", async function (value) { + return await nameIsAvailable(this.parent.id, value); + }), + description: yup.string().default(""), + templateId: yup.number().typeError("Template is required").required("Template is required").test("valid-template", "Invalid template", function (value) { + const setupData = this.options.context?.setupData as SetupData; + return setupData.templates.some(t => t.id === value); + }), + targetId: yup.number().typeError("Target is required").required("Target is required").test("valid-target", "Invalid target", function (value) { + const setupData = this.options.context?.setupData as SetupData; + return setupData.targets.some(t => t.id === value); + }), + statusCode: yup.string().default("ED"), + scheduleDate: yup.date().nullable(), + + // .when("statusCode", { + // is: (value: string) => value === "SC" || value === "SD", // String comparison + // then: (schema) => schema.required("Schedule date is required for scheduled or sending status"), + // otherwise: (schema) => schema.nullable(), + //}), + sentDate: yup.date().nullable().default(null), + sessionActivityId: yup.string().nullable(), + recurringTypeCode: yup + .string() + .nullable() + .oneOf(recurringTypeOptions.map((r) => r.code), "Invalid recurring type"), + recurringStartDate: yup.date().nullable() + + //.when("recurringTypeCode", { + //is: (value: string) => value !== "" && value !== null, // String comparison for "None" + //then: (schema) => schema.required("Recurring start date is required when recurring type is set"), + //otherwise: (schema) => schema.nullable(), + //}), +}); + +const nameIsAvailable = async (id: number, name: string) => { + const response = await fetch(`/api/mailings/available?${id > 0 ? "id=" + id + "&" : ""}name=${name}`); + return await response.json(); +}; + +const defaultMailing: Mailing = { + id: 0, + name: "", + description: "", + templateId: 0, + targetId: 0, + statusCode: "ED", + scheduleDate: null, + sentDate: null, + sessionActivityId: null, + recurringTypeCode: null, + recurringStartDate: null, +}; + +const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { + const isNew = !mailing || mailing.id === 0; + const setupData: SetupData = useSetupData(); + const [approved, setApproved] = useState(false); + const [testEmailListId, setTestEmailListId] = useState(null); + const [emails, setEmails] = useState([]); // State for email array + + const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm({ + mode: "onBlur", + defaultValues: { + ...(mailing || defaultMailing), + }, + resolver: yupResolver(schema) as Resolver, + context: { setupData }, + }); + + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + reset(mailing || defaultMailing, { keepDefaultValues: true }); + if (setupData.testEmailLists.length > 0) { + setTestEmailListId(setupData.testEmailLists[0].id); + setEmails(setupData.testEmailLists[0].emails); + } else { + setTestEmailListId(null); + setEmails([]); + } + } + }, [open, mailing, reset, setupData.testEmailLists]); + const handleSave = async (formData: Mailing) => { + const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`; + const method = isNew ? "POST" : "PUT"; + setLoading(true); + try { + const response = await fetch(apiUrl, { + method: method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update"); + + const updatedMailing = await response.json(); + onSave(updatedMailing); + onClose('saved'); + } catch (error) { + console.error("Update error:", error); + } finally { + setLoading(false); + } + }; + + const handleEmailsChange = (newEmails: string[]) => { + setEmails(newEmails); + }; + + const handleTestMailing = () => { + }; + + const handleTestEmailListChange = (list: TestEmailList | null) => { + if (list) { + setEmails(list.emails); + } + } + const handleApprovedChange = (event: React.ChangeEvent) => { + setApproved(event.target.checked); + }; + return ( + { onClose(reason); }} maxWidth="sm" fullWidth disableEscapeKeyDown > + {isNew ? "Add Mailing" : "Edit Mailing id=" + mailing?.id} + + + + ( + option.name} + value={setupData.templates.find(t => t.id === field.value) || null} + onChange={(_, newValue) => { + field.onChange(newValue ? newValue.id : null); + trigger("templateId"); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ( + option.name} + value={setupData.targets.find(t => t.id === field.value) || null} + onChange={(_, newValue) => { + field.onChange(newValue ? newValue.id : null); + trigger("targetId"); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + option.name} + value={setupData.testEmailLists.find(t => t.id === testEmailListId) || null} + onChange={(_, newValue) => handleTestEmailListChange(newValue)} + renderInput={(params) => ( + + )} + /> + + + + } label="Approved" /> + + {approved && (<> + + } label="Recurring Mailing" /> + } label="Schedule for Later" /> + + + )} + {/* ( + field.onChange(e.target.value ? new Date(e.target.value) : null)} + error={!!errors.scheduleDate} + helperText={errors.scheduleDate?.message} + /> + )} + /> + ( + option.name} + value={recurringTypeOptions.find(r => r.code === field.value) || null} + onChange={(_, newValue) => { + field.onChange(newValue ? newValue.code : null); + trigger("recurringTypeCode"); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ( + field.onChange(e.target.value ? new Date(e.target.value) : null)} + error={!!errors.recurringStartDate} + helperText={errors.recurringStartDate?.message} + /> + )} + /> + */} + + + + + + + ); +}; + +export default MailingEdit; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx index 481e3ca..83392bb 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx @@ -1,6 +1,7 @@ // App.tsx or main routing component -import React, { useEffect, ReactNode } from "react"; +import React from "react"; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider } from '@/components/auth/AuthContext'; import Layout from '@/components/layouts/Layout'; import LayoutLogin from '@/components/layouts/LayoutLogin'; @@ -13,27 +14,52 @@ import BouncedEmails from '@/components/pages/BouncedEmails'; import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls'; import Templates from '@/components/pages/Templates'; import EmailDomains from '@/components/pages/EmailDomains'; +import NewMailings from '@/components/pages/NewMailings'; +import AuthCheck from '@/components/auth/AuthCheck'; import { ColorModeContext } from '@/theme/theme'; import { SetupDataProvider } from '@/context/SetupDataContext'; -import { useTitle } from "@/context/TitleContext"; -import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { createTheme, ThemeProvider, Theme } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; +import ProtectedPageWrapper from '@/components/auth/ProtectedPageWrapper'; -interface PageWrapperProps { - title: string; - children: ReactNode; -} +const PageWrapper = ProtectedPageWrapper; -const PageWrapper: React.FC = ({ title, children }) => { - const { setTitle } = useTitle(); +//interface PageWrapperProps { +// title: string; +// children: ReactNode; +//} - useEffect(() => { - setTitle(title); - }, [title, setTitle]); +//const PageWrapper: React.FC = ({ title, children }) => { +// const { setTitle } = useTitle(); - return <>{children}; +// useEffect(() => { +// setTitle(title); +// }, [title, setTitle]); + +// return <>{children}; +//}; +const getTheme = (mode: 'light' | 'dark'): Theme => { + return createTheme({ + palette: { + mode, // Set the palette mode based on the parameter + }, + cssVariables: { + colorSchemeSelector: 'class', + }, + colorSchemes: { + light: true, // Default light scheme + dark: true, // Default dark scheme + }, + components: { + MuiAutocomplete: { + defaultProps: { + handleHomeEndKeys: false, + }, + }, + }, + }); }; const App = () => { @@ -48,115 +74,128 @@ const App = () => { [] ); - const theme = React.useMemo(() => createTheme({ palette: { mode } }), [mode]); + const theme = React.useMemo(() => getTheme(mode), [mode]); return ( - - - } /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - } - /> - - + + + + + } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } + /> + + + diff --git a/Surge365.MassEmailReact.Web/src/components/pages/AppMain.tsx b/Surge365.MassEmailReact.Web/src/components/pages/AppMain.tsx index a68b06a..1f2dc56 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/AppMain.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/AppMain.tsx @@ -1,80 +1,22 @@ import React from 'react'; import { createRoot } from 'react-dom/client' -import { createTheme, ThemeProvider } from '@mui/material/styles'; import '@/css/main.css' import App from '@/components/pages/App' import '@/config/constants'; import { TitleProvider } from "@/context/TitleContext"; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' -//DEFAULT THEMES -const theme = createTheme({ - cssVariables: { - colorSchemeSelector: 'class' - }, - colorSchemes: { - light: true, // Default light scheme - dark: true, // Default dark scheme - }, - components: { - MuiAppBar: { - styleOverrides: { - root: ({ theme }) => ({ - backgroundColor: theme.vars.palette.primary.main, - color: theme.vars.palette.text.primary, - }), - }, - }, - MuiOutlinedInput: { - styleOverrides: { - notchedOutline: ({ theme }) => ({ - borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[500] : theme.vars.palette.text.primary, - '&:hover': { - borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[700] : theme.vars.palette.text.primary, - }, - }), - root: ({ theme }) => ({ - '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[900] : theme.vars.palette.text.primary, - borderWidth: 2, // Match MUI default focus width - }, - }), - }, - }, - }, -}); - - -//CUSTOM THEMES -//const theme = createTheme({ -// cssVariables: { -// colorSchemeSelector: 'class' -// }, -// colorSchemes: { -// light: { -// palette: { -// primary: { main: '#1976d2' }, -// background: { default: '#fff', paper: '#f5f5f5' }, -// }, -// }, -// dark: { -// palette: { -// primary: { main: '#90caf9' }, -// background: { default: '#121212', paper: '#424242' }, -// }, -// }, -// }, -//}); - const rootElement = document.getElementById('root'); if (rootElement) { createRoot(rootElement).render( - + - + ); } else { diff --git a/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx b/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx index 00c5c57..fcd7572 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/Login.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button, TextField, @@ -9,7 +9,7 @@ import { Alert, } from '@mui/material'; import { AuthResponse, AuthErrorResponse, User, isAuthErrorResponse } from '@/types/auth'; -import utils from '@/ts/utils.ts'; +import utils from '@/ts/utils'; //import ForgotPasswordModal from '@/components/modals/ForgotPasswordModal'; type SpinnerState = Record; @@ -78,6 +78,7 @@ function Login() { parameters: { username, password }, success: (json: AuthResponse) => { try { + localStorage.setItem('accessToken', json.accessToken); loggedInUser = json.user; } catch { const errorMsg: string = 'Unexpected Error'; @@ -117,6 +118,19 @@ function Login() { } }; + useEffect(() => { //Reset app settings to clear out prev login + const resetAppSettings = async () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('session_currentUser'); + //localStorage.clear(); + //sessionStorage.clear(); + + await fetch('/api/authentication/logout', { method: 'POST', credentials: 'include' }); + }; + + resetAppSettings(); + }, []); + const finishUserLogin = async (loggedInUser: User) => { setIsLoading(false); setSpinners({ Login: false, LoginWithPasskey: false }); diff --git a/Surge365.MassEmailReact.Web/src/components/pages/NewMailings.tsx b/Surge365.MassEmailReact.Web/src/components/pages/NewMailings.tsx new file mode 100644 index 0000000..a23759d --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/pages/NewMailings.tsx @@ -0,0 +1,167 @@ +import { useState, useRef, useEffect } from 'react'; +import { useSetupData, SetupData } from "@/context/SetupDataContext"; +import EditIcon from '@mui/icons-material/Edit'; +import AddIcon from '@mui/icons-material/Add'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, CircularProgress, IconButton } from '@mui/material'; +import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid'; +import Mailing from '@/types/mailing'; +//import Template from '@/types/template'; +import MailingEdit from "@/components/modals/MailingEdit"; + +function NewMailings() { + const theme = useTheme(); + const setupData: SetupData = useSetupData(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const gridContainerRef = useRef(null); + const [mailingsLoading, setMailingsLoading] = useState(false); + const [mailings, setMailings] = useState([]); + const [selectedRow, setSelectedRow] = useState(null); + const [open, setOpen] = useState(false); + + const columns: GridColDef[] = [ + { + field: "actions", + headerName: "", + sortable: false, + width: 60, + renderCell: (params: GridRenderCellParams) => ( + { e.stopPropagation(); handleEdit(params.row); }}> + + + ), + }, + { field: "id", headerName: "ID", width: 80 }, + { field: "name", headerName: "Name", flex: 1, minWidth: 160 }, + { field: "description", headerName: "Description", flex: 1, minWidth: 200 }, + { + field: "templateId", + headerName: "Subject", + flex: 1, + minWidth: 160, + valueGetter: (_: number, row: Mailing) => setupData.templates.find(t => t.id === row.templateId)?.subject || 'Unknown', + }, + ]; + + const reloadMailings = async () => { + setMailingsLoading(true); + + const mailingsResponse = await fetch("/api/mailings/status/ed"); + const mailingsData = await mailingsResponse.json(); + if (mailingsData) { + setMailings(mailingsData); + setMailingsLoading(false); + } + else { + console.error("Failed to fetch mailings"); + setMailingsLoading(false); + } + } + + const handleNew = () => { + setSelectedRow(null); + setOpen(true); + }; + + const handleEdit = (row: GridRowModel) => { + setSelectedRow(row); + setOpen(true); + }; + + const handleUpdateRow = (updatedRow: Mailing) => { + updateMailings(updatedRow); + }; + + useEffect(() => { + reloadMailings(); + }, []); + + const updateMailings = async (updatedMailing: Mailing) => { + setMailings((prev) => { + const exists = prev.some((e) => e.id === updatedMailing.id); + + return exists + ? prev.map((server) => (server.id === updatedMailing.id ? updatedMailing : server)) + : [...prev, updatedMailing]; + }); + }; + return ( + + + {isMobile ? ( + + {mailings.map((row) => ( + + + + {row.name} + ID: {row.id} + Description: {row.description} + Subject: {setupData.templates.find(t => t.id === row.templateId)?.subject || 'Unknown'} + + { e.stopPropagation(); handleEdit(row); }}> + + + + + ))} + + ) : ( + ( + + + + + reloadMailings()} sx={{ marginLeft: 1 }}> + {mailingsLoading ? : } + + + + + + + ), + }} + slotProps={{ + toolbar: { + showQuickFilter: true, + }, + }} + initialState={{ + pagination: { + paginationModel: { + pageSize: 20, + }, + }, + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + )} + + + {open && ( + { if (reason !== 'backdropClick') setOpen(false) }} + onSave={handleUpdateRow} + /> + )} + + ); +} + +export default NewMailings; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/ts/utils.ts b/Surge365.MassEmailReact.Web/src/ts/utils.ts index ee63220..e00f158 100644 --- a/Surge365.MassEmailReact.Web/src/ts/utils.ts +++ b/Surge365.MassEmailReact.Web/src/ts/utils.ts @@ -11,6 +11,30 @@ export class ApiError extends Error { } const utils = { + isTokenExpired: (token: string): boolean => { + const payload = JSON.parse(atob(token.split('.')[1])); // Decode JWT payload + return payload.exp * 1000 < Date.now(); // Compare expiration (in ms) to current time + }, + getUserRoles: (token: string): string[] => { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'] || // Standard role claim + payload.role || // Fallback for custom 'role' claim + []; + } catch { + return []; + } + }, + + /*The following may not be needed any longer? + **TODO: WebMethod should be changed to mimic fetch command but add in auth headers? + fetch('/api/protected-endpoint', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('accessToken')}` + } + }); + + */ getCookie: (name: string): string | null => { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); @@ -160,7 +184,7 @@ const utils = { }, localStorageRemove: (key: string): void => { window.localStorage.removeItem(key); - } + }, }; declare global { diff --git a/Surge365.MassEmailReact.Web/src/types/mailing.ts b/Surge365.MassEmailReact.Web/src/types/mailing.ts new file mode 100644 index 0000000..85adce2 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/types/mailing.ts @@ -0,0 +1,15 @@ +export interface Mailing { + id: number; + name: string; + description: string; + templateId: number; + targetId: number; + statusCode: string; + scheduleDate: string | null; + sentDate: string | null; + sessionActivityId: string | null; + recurringTypeCode: string | null; + recurringStartDate: string | null; +} + +export default Mailing; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/vite.config.ts b/Surge365.MassEmailReact.Web/vite.config.ts index af8b534..5e16108 100644 --- a/Surge365.MassEmailReact.Web/vite.config.ts +++ b/Surge365.MassEmailReact.Web/vite.config.ts @@ -12,7 +12,7 @@ const baseFolder = ? `${env.APPDATA}/ASP.NET/https` : `${env.HOME}/.aspnet/https`; -const certificateName = "surge365.massemailreact.client"; +const certificateName = "Surge365.MassEmailReact.Web"; const certFilePath = path.join(baseFolder, `${certificateName}.pem`); const keyFilePath = path.join(baseFolder, `${certificateName}.key`);