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:
parent
ef75bdb779
commit
a5fd034a31
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
###
|
###
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
19
Surge365.MassEmailReact.Application/DTOs/MailingUpdateDto.cs
Normal file
19
Surge365.MassEmailReact.Application/DTOs/MailingUpdateDto.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
Surge365.MassEmailReact.Domain/Entities/Mailing.cs
Normal file
50
Surge365.MassEmailReact.Domain/Entities/Mailing.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
Surge365.MassEmailReact.Domain/Enums/MailingStatus.cs
Normal file
65
Surge365.MassEmailReact.Domain/Enums/MailingStatus.cs
Normal 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Surge365.MassEmailReact.Infrastructure/DapperMaps/UserMap.cs
Normal file
25
Surge365.MassEmailReact.Infrastructure/DapperMaps/UserMap.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 tokenDescriptor = new SecurityTokenDescriptor
|
var claims = new List<Claim>
|
||||||
{
|
|
||||||
Subject = new ClaimsIdentity(new[]
|
|
||||||
{
|
{
|
||||||
new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()),
|
new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()),
|
||||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
new Claim(ClaimTypes.Role, "User")
|
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(claims),
|
||||||
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),
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Surge365.MassEmailReact.Infrastructure/Utilities.cs
Normal file
19
Surge365.MassEmailReact.Infrastructure/Utilities.cs
Normal 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"] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
91
Surge365.MassEmailReact.Web/package-lock.json
generated
91
Surge365.MassEmailReact.Web/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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);
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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,15 +74,17 @@ 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>
|
||||||
|
<AuthProvider>
|
||||||
<Router basename="/">
|
<Router basename="/">
|
||||||
|
<AuthCheck />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/home"
|
path="/home"
|
||||||
element={
|
element={
|
||||||
@ -147,6 +175,16 @@ const App = () => {
|
|||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/newMailings"
|
||||||
|
element={
|
||||||
|
<PageWrapper title="New Mailings">
|
||||||
|
<Layout>
|
||||||
|
<NewMailings />
|
||||||
|
</Layout>
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
@ -157,6 +195,7 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
</SetupDataProvider>
|
</SetupDataProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ColorModeContext.Provider>
|
</ColorModeContext.Provider>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
167
Surge365.MassEmailReact.Web/src/components/pages/NewMailings.tsx
Normal file
167
Surge365.MassEmailReact.Web/src/components/pages/NewMailings.tsx
Normal 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;
|
||||||
@ -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 {
|
||||||
|
|||||||
15
Surge365.MassEmailReact.Web/src/types/mailing.ts
Normal file
15
Surge365.MassEmailReact.Web/src/types/mailing.ts
Normal 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;
|
||||||
@ -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`);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user