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.
This commit is contained in:
David Headrick 2025-03-21 07:38:46 -05:00
parent ef75bdb779
commit a5fd034a31
40 changed files with 1899 additions and 354 deletions

View File

@ -15,6 +15,20 @@ namespace Surge365.MassEmailReact.API.Controllers
_authService = authService; _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")] [HttpPost("authenticate")]
public async Task<IActionResult> Authenticate([FromBody] LoginRequest request) public async Task<IActionResult> Authenticate([FromBody] LoginRequest request)
{ {
@ -24,23 +38,45 @@ namespace Surge365.MassEmailReact.API.Controllers
else if(authResponse.data == null) else if(authResponse.data == null)
return Unauthorized(new { message = "Invalid credentials" }); 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 //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")] [HttpPost("refreshtoken")]
public IActionResult RefreshToken([FromBody] RefreshTokenRequest request) public async Task<IActionResult> 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) if (userId == null)
{ {
return Unauthorized("Invalid refresh token"); return Unauthorized("Invalid refresh token");
} }
var tokens = _authService.GenerateTokens(userId.Value, request.RefreshToken); var tokens = await _authService.GenerateTokens(userId.Value, refreshToken);
if(tokens == null) if(tokens == null)
return Unauthorized(); 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")] [HttpPost("generatepasswordrecovery")]
public IActionResult GeneratePasswordRecovery([FromBody] GeneratePasswordRecoveryRequest request) public IActionResult GeneratePasswordRecovery([FromBody] GeneratePasswordRecoveryRequest request)

View File

@ -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<IActionResult> GetNew()
//{
// var mailings = await _mailingService.GetByStatusAsync(BlastStatus.Editing.ToCode());
// return Ok(mailings);
//}
[HttpGet("available")]
public async Task<IActionResult> CheckNameAvailable([FromQuery] int? id, [FromQuery][Required] string name)
{
var available = await _mailingService.NameIsAvailableAsync(id, name);
return Ok(available);
}
[HttpGet("status/{statusCode}")]
public async Task<IActionResult> GetByStatus(string statusCode)
{
var mailings = await _mailingService.GetByStatusAsync(statusCode);
return Ok(mailings);
}
[HttpGet("{id}")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
}
}

View File

@ -28,6 +28,8 @@ builder.Services.AddScoped<ITemplateService, TemplateService>();
builder.Services.AddScoped<ITemplateRepository, TemplateRepository>(); builder.Services.AddScoped<ITemplateRepository, TemplateRepository>();
builder.Services.AddScoped<IEmailDomainService, EmailDomainService>(); builder.Services.AddScoped<IEmailDomainService, EmailDomainService>();
builder.Services.AddScoped<IEmailDomainRepository, EmailDomainRepository>(); builder.Services.AddScoped<IEmailDomainRepository, EmailDomainRepository>();
builder.Services.AddScoped<IMailingService, MailingService>();
builder.Services.AddScoped<IMailingRepository, MailingRepository>();
var app = builder.Build(); var app = builder.Build();

View File

@ -4,7 +4,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>..\surge365.massemailreact.client</SpaRoot> <SpaRoot>..\Surge365.MassEmailReact.Web</SpaRoot>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand> <SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<SpaProxyServerUrl>https://localhost:52871</SpaProxyServerUrl> <SpaProxyServerUrl>https://localhost:52871</SpaProxyServerUrl>
</PropertyGroup> </PropertyGroup>

View File

@ -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.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/ GET {{Surge365.MassEmailReact.API_HostAddress}}/servers/
Accept: application/json Accept: application/json
### ###
GET {{Surge365.MassEmailReact.UATServer_HostAddress}}/servers/get GET {{Surge365.MassEmailReact.API_HostAddress}}/servers/get
Accept: application/json Accept: application/json
### ###

View File

@ -10,6 +10,7 @@
"Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d" "Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d"
}, },
"AppCode": "MassEmailReactApi", "AppCode": "MassEmailReactApi",
"AuthAppCode": "MassEmailWeb",
"EnvironmentCode": "UAT", "EnvironmentCode": "UAT",
"ConnectionStrings": { "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 "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

View File

@ -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; }
}
}

View File

@ -5,6 +5,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
public interface IAuthService public interface IAuthService
{ {
Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password); 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);
} }
} }

View File

@ -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<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code);
Task<bool> NameIsAvailableAsync(int? id, string name);
Task<int?> CreateAsync(Mailing mailing);
Task<bool> UpdateAsync(Mailing mailing);
}
}

View File

@ -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<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code);
Task<bool> NameIsAvailableAsync(int? id, string name);
Task<int?> CreateAsync(MailingUpdateDto mailingDto);
Task<bool> UpdateAsync(MailingUpdateDto mailingDto);
}
}

View File

@ -11,5 +11,9 @@ namespace Surge365.MassEmailReact.Application.Interfaces
{ {
Task<(User? user, string message)> Authenticate(string username, string password); Task<(User? user, string message)> Authenticate(string username, string password);
bool Authenticate(Guid userId, string refreshToken); bool Authenticate(Guid userId, string refreshToken);
Task<User?> GetByUsername(string username);
Task<User?> GetByKey(int userKey);
Task<User?> GetById(Guid userId);
Task<List<User>> GetAll(bool activeOnly = true);
} }
} }

View File

@ -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);
}
}
}

View File

@ -10,25 +10,28 @@ namespace Surge365.MassEmailReact.Domain.Entities
{ {
public int? UserKey { get; private set; } public int? UserKey { get; private set; }
public Guid UserId { get; private set; } public Guid UserId { get; private set; }
public string Username { get; private set; } public string Username { get; private set; } = "";
public string FirstName { get; private set; } public string FirstName { get; private set; } = "";
public string MiddleInitial { get; private set; } public string MiddleInitial { get; private set; } = "";
public string LastName { get; private set; } public string LastName { get; private set; } = "";
public bool IsActive { get; private set; } public bool IsActive { get; private set; }
public List<string> Roles { get; private set; } = new List<string>();
private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive) public User() { }
{ //private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List<string> roles)
UserKey = userKey; //{
UserId = userId; // UserKey = userKey;
Username = username; // UserId = userId;
FirstName = firstName ?? ""; // Username = username;
MiddleInitial = middleInitial ?? ""; // FirstName = firstName ?? "";
LastName = lastName ?? ""; // MiddleInitial = middleInitial ?? "";
IsActive = isActive; // LastName = lastName ?? "";
} // IsActive = isActive;
public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive) // Roles = roles;
{ //}
return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive); //public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List<string> roles)
} //{
// return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive, roles);
//}
} }
} }

View File

@ -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)
};
}
}
}

View File

@ -1,4 +1,5 @@
using Dapper.FluentMap; using Dapper;
using Dapper.FluentMap;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -12,6 +13,8 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{ {
public static void ConfigureMappings() public static void ConfigureMappings()
{ {
SqlMapper.AddTypeHandler(new JsonListStringTypeHandler());
FluentMapper.Initialize(config => FluentMapper.Initialize(config =>
{ {
config.AddMap(new TargetMap()); config.AddMap(new TargetMap());
@ -21,6 +24,8 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
config.AddMap(new UnsubscribeUrlMap()); config.AddMap(new UnsubscribeUrlMap());
config.AddMap(new TemplateMap()); config.AddMap(new TemplateMap());
config.AddMap(new EmailDomainMap()); config.AddMap(new EmailDomainMap());
config.AddMap(new MailingMap());
config.AddMap(new UserMap());
}); });
} }
} }

View File

@ -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<List<string>>
{
public override List<string> Parse(object value)
{
if (value == null || value == DBNull.Value)
return new List<string>();
string json = value.ToString() ?? "";
return string.IsNullOrEmpty(json)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
}
public override void SetValue(IDbDataParameter parameter, List<string> value)
{
parameter.Value = value != null ? JsonSerializer.Serialize(value) : DBNull.Value;
}
}
}

View File

@ -0,0 +1,25 @@
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class MailingMap : EntityMap<Mailing>
{
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");
}
}
}

View File

@ -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<User>
{
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");
}
}
}

View File

@ -20,16 +20,10 @@ namespace Surge365.MassEmailReact.Infrastructure
return ""; return "";
return _configuration[$"ConnectionStrings:{connectionStringName}"] ?? ""; return _configuration[$"ConnectionStrings:{connectionStringName}"] ?? "";
} }
private string GetAppCode()
{
if (_configuration == null)
return "";
return _configuration[$"AppCode"] ?? "";
}
public DataAccess(IConfiguration configuration, string connectionStringName) public DataAccess(IConfiguration configuration, string connectionStringName)
{ {
_configuration = configuration; _configuration = configuration;
_connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", GetAppCode()); _connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", Utilities.GetAppCode(configuration));
} }
internal IConfiguration? _configuration; internal IConfiguration? _configuration;

View File

@ -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<Mailing?> GetByIdAsync(int id)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_by_id", new { blast_key = id }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
}
public async Task<List<Mailing>> GetAllAsync(bool activeOnly = true)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<List<Mailing>> GetByStatusAsync(string code)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<bool> 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<bool>("@available");
}
public async Task<int?> 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<bool>("@success");
if (success)
return parameters.Get<int>("@blast_key");
return null;
}
public async Task<bool> 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<bool>("@success");
}
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.Data.SqlClient; using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
@ -7,134 +8,107 @@ using Surge365.MassEmailReact.Domain.Enums.Extensions;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories 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 const string _connectionStringName = "Marketing.ConnectionString";
//private static readonly List<User> 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) public async Task<(User? user, string message)> Authenticate(string username, string password)
{ {
List<SqlParameter> pms = new List<SqlParameter>(); using var connection = new SqlConnection(ConnectionString);
pms.Add(new SqlParameter("username", username)); var parameters = new DynamicParameters();
pms.Add(new SqlParameter("password", password)); parameters.Add("username", username);
pms.Add(new SqlParameter("application_code", "MassEmailWeb")); //TODO: Pull from config 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); var result = await connection.QueryAsync<User>(
pmResponseNumber.Direction = ParameterDirection.Output; "adm_authenticate_login",
pms.Add(pmResponseNumber); parameters,
commandType: CommandType.StoredProcedure
);
SqlParameter pUserKey = new SqlParameter("login_key", SqlDbType.Int); var responseNumber = parameters.Get<short>("response_number");
pUserKey.Direction = ParameterDirection.Output; var authResult = (AuthResult)responseNumber;
pms.Add(pUserKey); string responseMessage = authResult.GetMessage();
DataAccess da = new DataAccess(_config, _connectionStringName); if (authResult == AuthResult.Success)
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 (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) var user = result.FirstOrDefault();
return (null, "No user row returned"); return (user, responseMessage);
return (LoadFromDataRow(ds.Tables[0].Rows[0]), responseMessage);
} }
return (null, responseMessage); return (null, responseMessage);
} }
public bool Authenticate(Guid userId, string refreshToken) public bool Authenticate(Guid userId, string refreshToken)
{ {
//TODO: Validate refresh token // TODO: Validate refresh token
return true; return true;
} }
public async Task<User?> GetByUsername(string username) public async Task<User?> GetByUsername(string username)
{ {
List<SqlParameter> pms = new List<SqlParameter>(); using var connection = new SqlConnection(ConnectionString);
pms.Add(new SqlParameter("username", username)); var parameters = new { username, application_code = AppCode };
return await connection.QueryFirstOrDefaultAsync<User>(
DataAccess da = new DataAccess(_config, _connectionStringName); "adm_get_login_by_username",
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_login_by_username"); parameters,
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) commandType: CommandType.StoredProcedure
return null; );
List<User> users = LoadFromDataRow(ds.Tables[0]);
return users.FirstOrDefault();
} }
public async Task<User?> GetByKey(int userKey) public async Task<User?> GetByKey(int userKey)
{ {
List<SqlParameter> pms = new List<SqlParameter>(); using var connection = new SqlConnection(ConnectionString);
pms.Add(new SqlParameter("login_key", userKey)); var parameters = new { login_key = userKey, application_code = AppCode };
return await connection.QueryFirstOrDefaultAsync<User>(
DataAccess da = new DataAccess(_config, _connectionStringName); "adm_get_adm_login_by_key",
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_key"); parameters,
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) commandType: CommandType.StoredProcedure
return null; );
List<User> users = LoadFromDataRow(ds.Tables[0]);
return users.FirstOrDefault();
} }
public async Task<User?> GetById(Guid userId) public async Task<User?> GetById(Guid userId)
{ {
List<SqlParameter> pms = new List<SqlParameter>(); using var connection = new SqlConnection(ConnectionString);
pms.Add(new SqlParameter("login_id", userId)); var parameters = new { login_id = userId, application_code = AppCode };
return await connection.QueryFirstOrDefaultAsync<User>(
DataAccess da = new DataAccess(_config, _connectionStringName); "adm_get_adm_login_by_id",
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_id"); parameters,
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) commandType: CommandType.StoredProcedure
return null; );
List<User> users = LoadFromDataRow(ds.Tables[0]);
return users.FirstOrDefault();
} }
public async Task<List<User>> GetAll(bool activeOnly = true) public async Task<List<User>> GetAll(bool activeOnly = true)
{ {
List<SqlParameter> pms = new List<SqlParameter>(); using var connection = new SqlConnection(ConnectionString);
pms.Add(new SqlParameter("active_only", activeOnly)); var parameters = new { active_only = activeOnly, application_code = AppCode };
var users = await connection.QueryAsync<User>(
DataAccess da = new DataAccess(_config, _connectionStringName); "adm_get_adm_login_all",
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all"); parameters,
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) commandType: CommandType.StoredProcedure
throw new Exception("No users returned"); );
return users.AsList();
return LoadFromDataRow(ds.Tables[0]);
}
//public void Add(User user)
//{
// Users.Add(user);
//}
private List<User> LoadFromDataRow(DataTable dt)
{
ArgumentNullException.ThrowIfNull(dt);
ArgumentNullException.ThrowIfNull(dt.Rows);
List<User> users = new List<User>();
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<int>("login_key"),
dr.Field<Guid>("login_id"),
dr.Field<string>("username")!,
dr.Field<string?>("first_name"),
dr.Field<string?>("middle_initial"),
dr.Field<string?>("last_name"),
dr.Field<bool>("is_active"));
} }
} }
} }

View File

@ -10,13 +10,14 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Data;
namespace Surge365.MassEmailReact.Infrastructure.Services namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
public class AuthService : IAuthService public class AuthService : IAuthService
{ {
private const int TOKEN_MINUTES = 60; private const int TOKEN_MINUTES = 5;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IConfiguration _config; private readonly IConfiguration _config;
@ -36,14 +37,16 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
// Generate JWT token // Generate JWT token
var tokenHandler = new JwtSecurityTokenHandler(); var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!); var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
var claims = new List<Claim>
{
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 var tokenDescriptor = new SecurityTokenDescriptor
{ {
Subject = new ClaimsIdentity(new[] Subject = new ClaimsIdentity(claims),
{
new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(ClaimTypes.Role, "User")
}),
Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES),
SigningCredentials = new SigningCredentials( SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key), new SymmetricSecurityKey(key),
@ -58,24 +61,31 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return (true, (authResponse.user, accessToken, refreshToken), ""); 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)) if (!_userRepository.Authenticate(userId, refreshToken))
{ {
return null; return null;
} }
var user = await _userRepository.GetById(userId);
if (user == null)
{
return null;
}
var tokenHandler = new JwtSecurityTokenHandler(); var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!); var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
var username = ""; var claims = new List<Claim>
{
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 //TODO: Look update User
var tokenDescriptor = new SecurityTokenDescriptor var tokenDescriptor = new SecurityTokenDescriptor
{ {
Subject = new ClaimsIdentity(new[] Subject = new ClaimsIdentity(claims),
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()!),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(ClaimTypes.Role, "User")
}),
Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES), Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES),
SigningCredentials = new SigningCredentials( SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key), new SymmetricSecurityKey(key),

View File

@ -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<Mailing?> GetByIdAsync(int id)
{
return await _mailingRepository.GetByIdAsync(id);
}
public async Task<List<Mailing>> GetAllAsync(bool activeOnly = true)
{
return await _mailingRepository.GetAllAsync(activeOnly);
}
public async Task<List<Mailing>> GetByStatusAsync(string statusCode)
{
return await _mailingRepository.GetByStatusAsync(statusCode);
}
public async Task<bool> NameIsAvailableAsync(int? id, string name)
{
return await _mailingRepository.NameIsAvailableAsync(id, name);
}
public async Task<int?> 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<bool> 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);
}
}
}

View File

@ -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"] ?? "";
}
}
}

View File

@ -4,11 +4,11 @@ The following tools were used to generate this project:
- create-vite - create-vite
The following steps were used to generate this project: 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. - Update `vite.config.ts` to set up proxying and certs.
- Add `@type/node` for `vite.config.js` typing. - Add `@type/node` for `vite.config.js` typing.
- Update `App` component to fetch and display weather information. - 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. - Create `launch.json` to enable debugging.
- Add project to solution. - Add project to solution.
- Update proxy endpoint to be the backend server endpoint. - Update proxy endpoint to be the backend server endpoint.

View File

@ -1,11 +1,11 @@
{ {
"name": "surge365.massemailreact.client", "name": "Surge365.MassEmailReact.Web",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "surge365.massemailreact.client", "name": "Surge365.MassEmailReact.Web",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@ -17,6 +17,7 @@
"@mui/material": "^6.4.5", "@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1", "@mui/x-charts": "^7.27.1",
"@mui/x-data-grid": "^7.27.1", "@mui/x-data-grid": "^7.27.1",
"@mui/x-date-pickers": "^7.28.0",
"admin-lte": "4.0.0-beta3", "admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"dayjs": "^1.11.13", "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": { "node_modules/@mui/x-internals": {
"version": "7.26.0", "version": "7.26.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz",

View File

@ -1,5 +1,5 @@
{ {
"name": "surge365.massemailreact.client", "name": "Surge365.MassEmailReact.Web",
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
@ -20,6 +20,7 @@
"@mui/material": "^6.4.5", "@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1", "@mui/x-charts": "^7.27.1",
"@mui/x-data-grid": "^7.27.1", "@mui/x-data-grid": "^7.27.1",
"@mui/x-date-pickers": "^7.28.0",
"admin-lte": "4.0.0-beta3", "admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",

View File

@ -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;

View File

@ -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<AuthContextType>({
accessToken: null,
userRoles: [],
setAuth: () => { },
});
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('accessToken'));
const [userRoles, setUserRoles] = useState<string[]>(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 (
<AuthContext.Provider value={{ accessToken, userRoles, setAuth }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

@ -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<string, string[]> = {
'/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;

View File

@ -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<EmailListProps> = ({ 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<HTMLInputElement>) => {
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 (
<TextField
label="Emails (one per line)"
margin="dense"
multiline
rows={4}
value={displayValue}
onChange={handleChange}
fullWidth
error={!!error} // Use the error prop if passed, otherwise compute locally
helperText={error || 'Enter each email on a new line'}
/>
);
};
export default EmailList;

View File

@ -1,8 +1,14 @@
// src/components/layouts/Layout.tsx // 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 React, { ReactNode, useEffect } from 'react';
import { useTheme, useMediaQuery } from '@mui/material'; import { useTheme, useMediaQuery } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { styled, useColorScheme } from '@mui/material/styles'; import { styled, useColorScheme } from '@mui/material/styles';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer'; import Drawer from '@mui/material/Drawer';
import AppBar from '@mui/material/AppBar'; import AppBar from '@mui/material/AppBar';
@ -19,6 +25,7 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import HttpIcon from '@mui/icons-material/Http'; import HttpIcon from '@mui/icons-material/Http';
import AccountBoxIcon from '@mui/icons-material/AccountBox';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import TargetIcon from '@mui/icons-material/TrackChanges'; 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 { Link as RouterLink } from 'react-router-dom';
import Select, { SelectChangeEvent } from '@mui/material/Select'; import Select, { SelectChangeEvent } from '@mui/material/Select';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl'; import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import { useTitle } from "@/context/TitleContext";
// Constants // Constants
const drawerWidth = 240; const drawerWidth = 240;
@ -77,6 +84,61 @@ const Layout = ({ children }: LayoutProps) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils? const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
const { title } = useTitle(); const { title } = useTitle();
const navigate = useNavigate();
const menuItems = [
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' },
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' },
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testEmailLists' },
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },
{ text: 'Email Domains', icon: <HttpIcon />, path: '/emailDomains' },
{ text: 'Templates', icon: <EmailIcon />, path: '/templates' },
{ text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' },
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' },
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
];
const { userRoles, setAuth } = useAuth(); // Use context
const [profileMenuAnchorEl, setProfileMenuAnchorEl] = React.useState<null | HTMLElement>(null);
const profileMenuOpen = Boolean(profileMenuAnchorEl);
const handleOpenProfileMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
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 = () => { const handleDrawerOpen = () => {
setOpen(true); setOpen(true);
@ -188,6 +250,29 @@ const Layout = ({ children }: LayoutProps) => {
<MenuItem value="dark">Dark</MenuItem> <MenuItem value="dark">Dark</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
<FormControl size="small">
<IconButton
color="inherit"
id="profile-menu-button"
aria-label="Profile Menu"
aria-controls={open ? 'profile-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
onClick={handleOpenProfileMenu}
sx={{ mr: 2 }}
>
<AccountBoxIcon />
</IconButton>
<Menu
id="profile-menu"
anchorEl={profileMenuAnchorEl}
open={profileMenuOpen}
onClose={handleCloseProfileMenu}
>
<MenuItem onClick={handleRefreshUser}>Refresh User</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</FormControl>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
@ -213,20 +298,21 @@ const Layout = ({ children }: LayoutProps) => {
</DrawerHeader> </DrawerHeader>
<Divider /> <Divider />
<List> <List>
{[ {/*{[*/}
{ text: 'Home', icon: <DashboardIcon />, path: '/home' }, {/* { text: 'Home', icon: <DashboardIcon />, path: '/home' },*/}
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' }, {/* { text: 'Servers', icon: <DnsIcon />, path: '/servers' },*/}
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' }, {/* { text: 'Targets', icon: <TargetIcon />, path: '/targets' },*/}
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testEmailLists' }, {/* { text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testEmailLists' },*/}
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' }, {/* { text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },*/}
{ text: 'Email Domains', icon: <HttpIcon />, path: '/emailDomains' }, {/* { text: 'Email Domains', icon: <HttpIcon />, path: '/emailDomains' },*/}
//{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' }, {/* //{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },*/}
{ text: 'Templates', icon: <EmailIcon />, path: '/templates' }, {/* { text: 'Templates', icon: <EmailIcon />, path: '/templates' },*/}
{ text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard? {/* { text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard?*/}
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, // {/* { text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //*/}
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' }, {/* { text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },*/}
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' }, {/* { text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },*/}
].map((item) => ( {/*].map((item) => (}*/}
{visibleMenuItems.map((item) => (
<ListItem key={item.text} disablePadding> <ListItem key={item.text} disablePadding>
<ListItemButton <ListItemButton
component={RouterLink} component={RouterLink}

View File

@ -0,0 +1,343 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Autocomplete,
DialogActions,
Button,
Checkbox,
FormControlLabel,
Box
} from "@mui/material";
import Mailing from "@/types/mailing";
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import EmailList from "@/components/forms/EmailList";
import TestEmailList from "@/types/testEmailList";
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
type MailingEditProps = {
open: boolean;
mailing: Mailing | null;
onClose: (reason: 'backdropClick' | 'escapeKeyDown' | 'saved' | 'cancelled') => 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<boolean>(false);
const [testEmailListId, setTestEmailListId] = useState<number | null>(null);
const [emails, setEmails] = useState<string[]>([]); // State for email array
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Mailing>({
mode: "onBlur",
defaultValues: {
...(mailing || defaultMailing),
},
resolver: yupResolver(schema) as Resolver<Mailing>,
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<HTMLInputElement>) => {
setApproved(event.target.checked);
};
return (
<Dialog open={open} onClose={(_, reason) => { onClose(reason); }} maxWidth="sm" fullWidth disableEscapeKeyDown >
<DialogTitle>{isNew ? "Add Mailing" : "Edit Mailing id=" + mailing?.id}</DialogTitle>
<DialogContent>
<TextField
{...register("name")}
label="Name"
fullWidth
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
{...register("description")}
label="Description"
fullWidth
margin="dense"
error={!!errors.description}
helperText={errors.description?.message}
/>
<Controller
name="templateId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.templates}
getOptionLabel={(option) => option.name}
value={setupData.templates.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("templateId");
}}
renderInput={(params) => (
<TextField
{...params}
label="Template"
fullWidth
margin="dense"
error={!!errors.templateId}
helperText={errors.templateId?.message}
/>
)}
/>
)}
/>
<Controller
name="targetId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.targets}
getOptionLabel={(option) => option.name}
value={setupData.targets.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("targetId");
}}
renderInput={(params) => (
<TextField
{...params}
label="Target"
fullWidth
margin="dense"
error={!!errors.targetId}
helperText={errors.targetId?.message}
/>
)}
/>
)}
/>
<Autocomplete
options={setupData.testEmailLists}
getOptionLabel={(option) => option.name}
value={setupData.testEmailLists.find(t => t.id === testEmailListId) || null}
onChange={(_, newValue) => handleTestEmailListChange(newValue)}
renderInput={(params) => (
<TextField
{...params}
label="Test Email List"
fullWidth
margin="dense"
/>
)}
/>
<EmailList emails={emails} onEmailTextChange={handleEmailsChange} />
<Button onClick={handleTestMailing} disabled={loading}>Test Mailing</Button>
<Box>
<FormControlLabel control={<Checkbox checked={approved} onChange={handleApprovedChange} />} label="Approved" />
</Box>
{approved && (<>
<Box sx={{ display: 'flex', flexDirection: 'column', ml: 3 }}>
<FormControlLabel control={<Checkbox />} label="Recurring Mailing" />
<FormControlLabel control={<Checkbox />} label="Schedule for Later" />
<DatePicker />
</Box>
</>)}
{/*<Controller
name="scheduleDate"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Schedule Date"
type="datetime-local"
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
value={field.value ? new Date(field.value).toISOString().slice(0, 16) : ''}
onChange={(e) => field.onChange(e.target.value ? new Date(e.target.value) : null)}
error={!!errors.scheduleDate}
helperText={errors.scheduleDate?.message}
/>
)}
/>
<Controller
name="recurringTypeCode"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={recurringTypeOptions}
getOptionLabel={(option) => option.name}
value={recurringTypeOptions.find(r => r.code === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.code : null);
trigger("recurringTypeCode");
}}
renderInput={(params) => (
<TextField
{...params}
label="Recurring Type"
fullWidth
margin="dense"
error={!!errors.recurringTypeCode}
helperText={errors.recurringTypeCode?.message}
/>
)}
/>
)}
/>
<Controller
name="recurringStartDate"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Recurring Start Date"
type="datetime-local"
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
value={field.value ? new Date(field.value).toISOString().slice(0, 16) : ''}
onChange={(e) => field.onChange(e.target.value ? new Date(e.target.value) : null)}
error={!!errors.recurringStartDate}
helperText={errors.recurringStartDate?.message}
/>
)}
/>
*/}
</DialogContent>
<DialogActions>
<Button onClick={() => { onClose( 'cancelled'); }} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default MailingEdit;

View File

@ -1,6 +1,7 @@
// App.tsx or main routing component // 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 { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from '@/components/auth/AuthContext';
import Layout from '@/components/layouts/Layout'; import Layout from '@/components/layouts/Layout';
import LayoutLogin from '@/components/layouts/LayoutLogin'; import LayoutLogin from '@/components/layouts/LayoutLogin';
@ -13,27 +14,52 @@ import BouncedEmails from '@/components/pages/BouncedEmails';
import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls'; import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls';
import Templates from '@/components/pages/Templates'; import Templates from '@/components/pages/Templates';
import EmailDomains from '@/components/pages/EmailDomains'; import EmailDomains from '@/components/pages/EmailDomains';
import NewMailings from '@/components/pages/NewMailings';
import AuthCheck from '@/components/auth/AuthCheck';
import { ColorModeContext } from '@/theme/theme'; import { ColorModeContext } from '@/theme/theme';
import { SetupDataProvider } from '@/context/SetupDataContext'; 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 CssBaseline from '@mui/material/CssBaseline';
import ProtectedPageWrapper from '@/components/auth/ProtectedPageWrapper';
interface PageWrapperProps { const PageWrapper = ProtectedPageWrapper;
title: string;
children: ReactNode;
}
const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => { //interface PageWrapperProps {
const { setTitle } = useTitle(); // title: string;
// children: ReactNode;
//}
useEffect(() => { //const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => {
setTitle(title); // const { setTitle } = useTitle();
}, [title, setTitle]);
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 = () => { const App = () => {
@ -48,115 +74,128 @@ const App = () => {
[] []
); );
const theme = React.useMemo(() => createTheme({ palette: { mode } }), [mode]); const theme = React.useMemo(() => getTheme(mode), [mode]);
return ( return (
<ColorModeContext.Provider value={colorMode}> <ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<SetupDataProvider> <SetupDataProvider>
<Router basename="/"> <AuthProvider>
<Routes> <Router basename="/">
<Route path="/" element={<Navigate to="/login" replace />} /> <AuthCheck />
<Route <Routes>
path="/home" <Route path="/" element={<Navigate to="/home" replace />} />
element={ <Route
<PageWrapper title="Dashboard"> path="/home"
<Layout> element={
<Home /> <PageWrapper title="Dashboard">
</Layout> <Layout>
</PageWrapper> <Home />
} </Layout>
/> </PageWrapper>
<Route }
path="/servers" />
element={ <Route
<PageWrapper title="Servers"> path="/servers"
<Layout> element={
<Servers /> <PageWrapper title="Servers">
</Layout> <Layout>
</PageWrapper> <Servers />
} </Layout>
/> </PageWrapper>
<Route }
path="/targets" />
element={ <Route
<PageWrapper title="Targets"> path="/targets"
<Layout> element={
<Targets /> <PageWrapper title="Targets">
</Layout> <Layout>
</PageWrapper> <Targets />
} </Layout>
/> </PageWrapper>
<Route }
path="/testEmailLists" />
element={ <Route
<PageWrapper title="Test Email Lists"> path="/testEmailLists"
<Layout> element={
<TestEmailLists /> <PageWrapper title="Test Email Lists">
</Layout> <Layout>
</PageWrapper> <TestEmailLists />
} </Layout>
/> </PageWrapper>
<Route }
path="/blockedEmails" />
element={ <Route
<PageWrapper title="Blocked Emails"> path="/blockedEmails"
<Layout> element={
<BouncedEmails /> <PageWrapper title="Blocked Emails">
</Layout> <Layout>
</PageWrapper> <BouncedEmails />
} </Layout>
/> </PageWrapper>
<Route }
path="/blockedEmails" />
element={ <Route
<PageWrapper title="Blocked Emails"> path="/blockedEmails"
<Layout> element={
<BouncedEmails /> <PageWrapper title="Blocked Emails">
</Layout> <Layout>
</PageWrapper> <BouncedEmails />
} </Layout>
/> </PageWrapper>
<Route }
path="/emailDomains" />
element={ <Route
<PageWrapper title="Email Domains"> path="/emailDomains"
<Layout> element={
<EmailDomains /> <PageWrapper title="Email Domains">
</Layout> <Layout>
</PageWrapper> <EmailDomains />
} </Layout>
/> </PageWrapper>
<Route }
path="/unsubscribeUrls" />
element={ <Route
<PageWrapper title="Unsubscribe Urls"> path="/unsubscribeUrls"
<Layout> element={
<UnsubscribeUrls /> <PageWrapper title="Unsubscribe Urls">
</Layout> <Layout>
</PageWrapper> <UnsubscribeUrls />
} </Layout>
/> </PageWrapper>
<Route }
path="/templates" />
element={ <Route
<PageWrapper title="Templates"> path="/templates"
<Layout> element={
<Templates /> <PageWrapper title="Templates">
</Layout> <Layout>
</PageWrapper> <Templates />
} </Layout>
/> </PageWrapper>
<Route }
path="/login" />
element={ <Route
<LayoutLogin> path="/newMailings"
<Login /> element={
</LayoutLogin> <PageWrapper title="New Mailings">
} <Layout>
/> <NewMailings />
</Routes> </Layout>
</Router> </PageWrapper>
}
/>
<Route
path="/login"
element={
<LayoutLogin>
<Login />
</LayoutLogin>
}
/>
</Routes>
</Router>
</AuthProvider>
</SetupDataProvider> </SetupDataProvider>
</ThemeProvider> </ThemeProvider>
</ColorModeContext.Provider> </ColorModeContext.Provider>

View File

@ -1,80 +1,22 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { createTheme, ThemeProvider } from '@mui/material/styles';
import '@/css/main.css' import '@/css/main.css'
import App from '@/components/pages/App' import App from '@/components/pages/App'
import '@/config/constants'; import '@/config/constants';
import { TitleProvider } from "@/context/TitleContext"; 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'); const rootElement = document.getElementById('root');
if (rootElement) { if (rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme} defaultMode="system"> <LocalizationProvider dateAdapter={AdapterDayjs}>
<TitleProvider> <TitleProvider>
<App /> <App />
</TitleProvider> </TitleProvider>
</ThemeProvider> </LocalizationProvider>
</React.StrictMode> </React.StrictMode>
); );
} else { } else {

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Button, Button,
TextField, TextField,
@ -9,7 +9,7 @@ import {
Alert, Alert,
} from '@mui/material'; } from '@mui/material';
import { AuthResponse, AuthErrorResponse, User, isAuthErrorResponse } from '@/types/auth'; 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'; //import ForgotPasswordModal from '@/components/modals/ForgotPasswordModal';
type SpinnerState = Record<string, boolean>; type SpinnerState = Record<string, boolean>;
@ -78,6 +78,7 @@ function Login() {
parameters: { username, password }, parameters: { username, password },
success: (json: AuthResponse) => { success: (json: AuthResponse) => {
try { try {
localStorage.setItem('accessToken', json.accessToken);
loggedInUser = json.user; loggedInUser = json.user;
} catch { } catch {
const errorMsg: string = 'Unexpected Error'; 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) => { const finishUserLogin = async (loggedInUser: User) => {
setIsLoading(false); setIsLoading(false);
setSpinners({ Login: false, LoginWithPasskey: false }); setSpinners({ Login: false, LoginWithPasskey: false });

View File

@ -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<HTMLDivElement | null>(null);
const [mailingsLoading, setMailingsLoading] = useState<boolean>(false);
const [mailings, setMailings] = useState<Mailing[]>([]);
const [selectedRow, setSelectedRow] = useState<Mailing | null>(null);
const [open, setOpen] = useState<boolean>(false);
const columns: GridColDef<Mailing>[] = [
{
field: "actions",
headerName: "",
sortable: false,
width: 60,
renderCell: (params: GridRenderCellParams<Mailing>) => (
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleEdit(params.row); }}>
<EditIcon />
</IconButton>
),
},
{ 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<Mailing>) => {
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 (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{mailings.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">Description: {row.description}</Typography>
<Typography variant="body2">Subject: {setupData.templates.find(t => t.id === row.templateId)?.subject || 'Unknown'}</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={mailings}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<IconButton size="small" color="primary" onClick={handleNew} sx={{ marginLeft: 1 }}>
<AddIcon />
</IconButton>
<IconButton size="small" color="primary" onClick={() => reloadMailings()} sx={{ marginLeft: 1 }}>
{mailingsLoading ? <CircularProgress size={24} color="inherit" /> : <RefreshIcon />}
</IconButton>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{open && (
<MailingEdit
open={open}
mailing={selectedRow}
onClose={(reason) => { if (reason !== 'backdropClick') setOpen(false) }}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default NewMailings;

View File

@ -11,6 +11,30 @@ export class ApiError extends Error {
} }
const utils = { 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 => { getCookie: (name: string): string | null => {
const value = `; ${document.cookie}`; const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`); const parts = value.split(`; ${name}=`);
@ -160,7 +184,7 @@ const utils = {
}, },
localStorageRemove: (key: string): void => { localStorageRemove: (key: string): void => {
window.localStorage.removeItem(key); window.localStorage.removeItem(key);
} },
}; };
declare global { declare global {

View File

@ -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;

View File

@ -12,7 +12,7 @@ const baseFolder =
? `${env.APPDATA}/ASP.NET/https` ? `${env.APPDATA}/ASP.NET/https`
: `${env.HOME}/.aspnet/https`; : `${env.HOME}/.aspnet/https`;
const certificateName = "surge365.massemailreact.client"; const certificateName = "Surge365.MassEmailReact.Web";
const certFilePath = path.join(baseFolder, `${certificateName}.pem`); const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`); const keyFilePath = path.join(baseFolder, `${certificateName}.key`);