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;
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
Response.Cookies.Append("refreshToken", "", new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(-1) // Expire immediately
|
||||
});
|
||||
|
||||
return Ok(new { message = "Logged out successfully" });
|
||||
}
|
||||
|
||||
[HttpPost("authenticate")]
|
||||
public async Task<IActionResult> Authenticate([FromBody] LoginRequest request)
|
||||
{
|
||||
@ -24,23 +38,45 @@ namespace Surge365.MassEmailReact.API.Controllers
|
||||
else if(authResponse.data == null)
|
||||
return Unauthorized(new { message = "Invalid credentials" });
|
||||
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true, // Prevents JavaScript access (mitigates XSS)
|
||||
Secure = true, // Ensures cookie is only sent over HTTPS
|
||||
SameSite = SameSiteMode.Strict, // Mitigates CSRF by restricting cross-site usage
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(7)
|
||||
};
|
||||
Response.Cookies.Append("refreshToken", authResponse.data.Value.refreshToken, cookieOptions);
|
||||
|
||||
//TODO: Store user in session
|
||||
return Ok(new { success = true, authResponse.data.Value.accessToken, authResponse.data.Value.user }); //TODO: Send refresh token in http only cookie.
|
||||
return Ok(new { success = true, authResponse.data.Value.accessToken, authResponse.data.Value.user });
|
||||
}
|
||||
[HttpPost("refreshtoken")]
|
||||
public IActionResult RefreshToken([FromBody] RefreshTokenRequest request)
|
||||
public async Task<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)
|
||||
{
|
||||
return Unauthorized("Invalid refresh token");
|
||||
}
|
||||
|
||||
var tokens = _authService.GenerateTokens(userId.Value, request.RefreshToken);
|
||||
var tokens = await _authService.GenerateTokens(userId.Value, refreshToken);
|
||||
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")]
|
||||
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<IEmailDomainService, EmailDomainService>();
|
||||
builder.Services.AddScoped<IEmailDomainRepository, EmailDomainRepository>();
|
||||
builder.Services.AddScoped<IMailingService, MailingService>();
|
||||
builder.Services.AddScoped<IMailingRepository, MailingRepository>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SpaRoot>..\surge365.massemailreact.client</SpaRoot>
|
||||
<SpaRoot>..\Surge365.MassEmailReact.Web</SpaRoot>
|
||||
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
|
||||
<SpaProxyServerUrl>https://localhost:52871</SpaProxyServerUrl>
|
||||
</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.API_HostAddress = http://localhost:5065/api
|
||||
|
||||
# Step 1: Authenticate to get the refresh token cookie
|
||||
POST {{Surge365.MassEmailReact.API_HostAddress}}/authentication/authenticate
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
{
|
||||
"username": "dheadrick",
|
||||
"password": "Password1"
|
||||
}
|
||||
###
|
||||
# Step 2: Call refreshtoken with the cookie
|
||||
POST {{Surge365.MassEmailReact.API_HostAddress}}/authentication/refreshtoken
|
||||
Accept: application/json
|
||||
Cookie: refreshToken=hhlLpqHP0kiYhyyBDr9hZw==
|
||||
###
|
||||
|
||||
GET {{Surge365.MassEmailReact.API_HostAddress}}/servers/
|
||||
Accept: application/json
|
||||
###
|
||||
|
||||
GET {{Surge365.MassEmailReact.UATServer_HostAddress}}/servers/get
|
||||
GET {{Surge365.MassEmailReact.API_HostAddress}}/servers/get
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d"
|
||||
},
|
||||
"AppCode": "MassEmailReactApi",
|
||||
"AuthAppCode": "MassEmailWeb",
|
||||
"EnvironmentCode": "UAT",
|
||||
"ConnectionStrings": {
|
||||
"Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
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 Guid UserId { get; private set; }
|
||||
public string Username { get; private set; }
|
||||
public string FirstName { get; private set; }
|
||||
public string MiddleInitial { get; private set; }
|
||||
public string LastName { get; private set; }
|
||||
public string Username { get; private set; } = "";
|
||||
public string FirstName { get; private set; } = "";
|
||||
public string MiddleInitial { get; private set; } = "";
|
||||
public string LastName { get; private set; } = "";
|
||||
public bool IsActive { get; private set; }
|
||||
public List<string> Roles { get; private set; } = new List<string>();
|
||||
|
||||
private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive)
|
||||
{
|
||||
UserKey = userKey;
|
||||
UserId = userId;
|
||||
Username = username;
|
||||
FirstName = firstName ?? "";
|
||||
MiddleInitial = middleInitial ?? "";
|
||||
LastName = lastName ?? "";
|
||||
IsActive = isActive;
|
||||
}
|
||||
public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive)
|
||||
{
|
||||
return new User(userKey, userId, username, firstName, middleInitial, lastName, isActive);
|
||||
}
|
||||
public User() { }
|
||||
//private User(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List<string> roles)
|
||||
//{
|
||||
// UserKey = userKey;
|
||||
// UserId = userId;
|
||||
// Username = username;
|
||||
// FirstName = firstName ?? "";
|
||||
// MiddleInitial = middleInitial ?? "";
|
||||
// LastName = lastName ?? "";
|
||||
// IsActive = isActive;
|
||||
// Roles = roles;
|
||||
//}
|
||||
//public static User Create(int userKey, Guid userId, string username, string? firstName, string? middleInitial, string? lastName, bool isActive, List<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 System;
|
||||
using System.Collections.Generic;
|
||||
@ -12,6 +13,8 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
|
||||
{
|
||||
public static void ConfigureMappings()
|
||||
{
|
||||
SqlMapper.AddTypeHandler(new JsonListStringTypeHandler());
|
||||
|
||||
FluentMapper.Initialize(config =>
|
||||
{
|
||||
config.AddMap(new TargetMap());
|
||||
@ -21,6 +24,8 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
|
||||
config.AddMap(new UnsubscribeUrlMap());
|
||||
config.AddMap(new TemplateMap());
|
||||
config.AddMap(new EmailDomainMap());
|
||||
config.AddMap(new MailingMap());
|
||||
config.AddMap(new UserMap());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 _configuration[$"ConnectionStrings:{connectionStringName}"] ?? "";
|
||||
}
|
||||
private string GetAppCode()
|
||||
{
|
||||
if (_configuration == null)
|
||||
return "";
|
||||
return _configuration[$"AppCode"] ?? "";
|
||||
}
|
||||
public DataAccess(IConfiguration configuration, string connectionStringName)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", GetAppCode());
|
||||
_connectionString = GetConnectionString(connectionStringName).Replace("##application_code##", Utilities.GetAppCode(configuration));
|
||||
}
|
||||
|
||||
internal IConfiguration? _configuration;
|
||||
|
||||
@ -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 Surge365.MassEmailReact.Application.Interfaces;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
@ -7,134 +8,107 @@ using Surge365.MassEmailReact.Domain.Enums.Extensions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
{
|
||||
public class UserRepository (IConfiguration config) : IUserRepository
|
||||
public class UserRepository : IUserRepository
|
||||
{
|
||||
private IConfiguration _config = config;
|
||||
private readonly IConfiguration _config;
|
||||
private const string _connectionStringName = "Marketing.ConnectionString";
|
||||
|
||||
//private static readonly List<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)
|
||||
{
|
||||
List<SqlParameter> pms = new List<SqlParameter>();
|
||||
pms.Add(new SqlParameter("username", username));
|
||||
pms.Add(new SqlParameter("password", password));
|
||||
pms.Add(new SqlParameter("application_code", "MassEmailWeb")); //TODO: Pull from config
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("username", username);
|
||||
parameters.Add("password", password);
|
||||
parameters.Add("application_code", AppCode);
|
||||
parameters.Add("response_number", dbType: DbType.Int16, direction: ParameterDirection.Output);
|
||||
parameters.Add("login_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
|
||||
|
||||
SqlParameter pmResponseNumber = new SqlParameter("response_number", SqlDbType.SmallInt);
|
||||
pmResponseNumber.Direction = ParameterDirection.Output;
|
||||
pms.Add(pmResponseNumber);
|
||||
var result = await connection.QueryAsync<User>(
|
||||
"adm_authenticate_login",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
|
||||
SqlParameter pUserKey = new SqlParameter("login_key", SqlDbType.Int);
|
||||
pUserKey.Direction = ParameterDirection.Output;
|
||||
pms.Add(pUserKey);
|
||||
var responseNumber = parameters.Get<short>("response_number");
|
||||
var authResult = (AuthResult)responseNumber;
|
||||
string responseMessage = authResult.GetMessage();
|
||||
|
||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_authenticate_login");
|
||||
|
||||
var result = (AuthResult)Convert.ToInt16(pmResponseNumber.Value);
|
||||
|
||||
string responseMessage = AuthResultExtensions.GetMessage(result);
|
||||
if (result == AuthResult.Success)
|
||||
if (authResult == AuthResult.Success)
|
||||
{
|
||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||
return (null, "No user row returned");
|
||||
return (LoadFromDataRow(ds.Tables[0].Rows[0]), responseMessage);
|
||||
var user = result.FirstOrDefault();
|
||||
return (user, responseMessage);
|
||||
}
|
||||
return (null, responseMessage);
|
||||
}
|
||||
|
||||
public bool Authenticate(Guid userId, string refreshToken)
|
||||
{
|
||||
// TODO: Validate refresh token
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<User?> GetByUsername(string username)
|
||||
{
|
||||
List<SqlParameter> pms = new List<SqlParameter>();
|
||||
pms.Add(new SqlParameter("username", username));
|
||||
|
||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_login_by_username");
|
||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||
return null;
|
||||
|
||||
List<User> users = LoadFromDataRow(ds.Tables[0]);
|
||||
return users.FirstOrDefault();
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var parameters = new { username, application_code = AppCode };
|
||||
return await connection.QueryFirstOrDefaultAsync<User>(
|
||||
"adm_get_login_by_username",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<User?> GetByKey(int userKey)
|
||||
{
|
||||
List<SqlParameter> pms = new List<SqlParameter>();
|
||||
pms.Add(new SqlParameter("login_key", userKey));
|
||||
|
||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_key");
|
||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||
return null;
|
||||
|
||||
List<User> users = LoadFromDataRow(ds.Tables[0]);
|
||||
return users.FirstOrDefault();
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var parameters = new { login_key = userKey, application_code = AppCode };
|
||||
return await connection.QueryFirstOrDefaultAsync<User>(
|
||||
"adm_get_adm_login_by_key",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<User?> GetById(Guid userId)
|
||||
{
|
||||
List<SqlParameter> pms = new List<SqlParameter>();
|
||||
pms.Add(new SqlParameter("login_id", userId));
|
||||
|
||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_by_id");
|
||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||
return null;
|
||||
|
||||
List<User> users = LoadFromDataRow(ds.Tables[0]);
|
||||
return users.FirstOrDefault();
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var parameters = new { login_id = userId, application_code = AppCode };
|
||||
return await connection.QueryFirstOrDefaultAsync<User>(
|
||||
"adm_get_adm_login_by_id",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<User>> GetAll(bool activeOnly = true)
|
||||
{
|
||||
List<SqlParameter> pms = new List<SqlParameter>();
|
||||
pms.Add(new SqlParameter("active_only", activeOnly));
|
||||
|
||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all");
|
||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||
throw new Exception("No users returned");
|
||||
|
||||
return LoadFromDataRow(ds.Tables[0]);
|
||||
}
|
||||
|
||||
|
||||
//public void Add(User user)
|
||||
//{
|
||||
// Users.Add(user);
|
||||
//}
|
||||
|
||||
private List<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"));
|
||||
|
||||
using var connection = new SqlConnection(ConnectionString);
|
||||
var parameters = new { active_only = activeOnly, application_code = AppCode };
|
||||
var users = await connection.QueryAsync<User>(
|
||||
"adm_get_adm_login_all",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
return users.AsList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,13 +10,14 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Data;
|
||||
|
||||
|
||||
namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
{
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private const int TOKEN_MINUTES = 60;
|
||||
private const int TOKEN_MINUTES = 5;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
@ -36,14 +37,16 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
// Generate JWT token
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(new[]
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, authResponse.user.UserId.ToString()),
|
||||
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),
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(key),
|
||||
@ -58,24 +61,31 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
|
||||
return (true, (authResponse.user, accessToken, refreshToken), "");
|
||||
}
|
||||
public (string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken)
|
||||
public async Task<(string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string refreshToken)
|
||||
{
|
||||
if (!_userRepository.Authenticate(userId, refreshToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var user = await _userRepository.GetById(userId);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.UTF8.GetBytes(_config["Jwt:Secret"]!);
|
||||
var username = "";
|
||||
var claims = new List<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
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()!),
|
||||
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new Claim(ClaimTypes.Role, "User")
|
||||
}),
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddMinutes(TOKEN_MINUTES),
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(key),
|
||||
|
||||
@ -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
|
||||
|
||||
The following steps were used to generate this project:
|
||||
- Create react project with create-vite: `npm init --yes vite@latest surge365.massemailreact.client -- --template=react-ts`.
|
||||
- Create react project with create-vite: `npm init --yes vite@latest Surge365.MassEmailReact.Web -- --template=react-ts`.
|
||||
- Update `vite.config.ts` to set up proxying and certs.
|
||||
- Add `@type/node` for `vite.config.js` typing.
|
||||
- Update `App` component to fetch and display weather information.
|
||||
- Create project file (`surge365.massemailreact.client.esproj`).
|
||||
- Create project file (`Surge365.MassEmailReact.Web.esproj`).
|
||||
- Create `launch.json` to enable debugging.
|
||||
- Add project to solution.
|
||||
- Update proxy endpoint to be the backend server endpoint.
|
||||
|
||||
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",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "surge365.massemailreact.client",
|
||||
"name": "Surge365.MassEmailReact.Web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -17,6 +17,7 @@
|
||||
"@mui/material": "^6.4.5",
|
||||
"@mui/x-charts": "^7.27.1",
|
||||
"@mui/x-data-grid": "^7.27.1",
|
||||
"@mui/x-date-pickers": "^7.28.0",
|
||||
"admin-lte": "4.0.0-beta3",
|
||||
"bootstrap": "^5.3.3",
|
||||
"dayjs": "^1.11.13",
|
||||
@ -1612,6 +1613,92 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.28.0.tgz",
|
||||
"integrity": "sha512-m1bfkZLOw3cMogeh6q92SjykVmLzfptnz3ZTgAlFKV7UBnVFuGUITvmwbgTZ1Mz3FmLVnGUQYUpZWw0ZnoghNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta",
|
||||
"@mui/x-internals": "7.28.0",
|
||||
"@types/react-transition-group": "^4.4.11",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta",
|
||||
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta",
|
||||
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||
"dayjs": "^1.10.7",
|
||||
"luxon": "^3.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns-jalali": {
|
||||
"optional": true
|
||||
},
|
||||
"dayjs": {
|
||||
"optional": true
|
||||
},
|
||||
"luxon": {
|
||||
"optional": true
|
||||
},
|
||||
"moment": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-hijri": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-jalaali": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers/node_modules/@mui/x-internals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.28.0.tgz",
|
||||
"integrity": "sha512-p4GEp/09bLDumktdIMiw+OF4p+pJOOjTG0VUvzNxjbHB9GxbBKoMcHrmyrURqoBnQpWIeFnN/QAoLMFSpfwQbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-internals": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "surge365.massemailreact.client",
|
||||
"name": "Surge365.MassEmailReact.Web",
|
||||
"homepage": ".",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
@ -20,6 +20,7 @@
|
||||
"@mui/material": "^6.4.5",
|
||||
"@mui/x-charts": "^7.27.1",
|
||||
"@mui/x-data-grid": "^7.27.1",
|
||||
"@mui/x-date-pickers": "^7.28.0",
|
||||
"admin-lte": "4.0.0-beta3",
|
||||
"bootstrap": "^5.3.3",
|
||||
"dayjs": "^1.11.13",
|
||||
|
||||
@ -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
|
||||
|
||||
import { useTitle } from "@/context/TitleContext";
|
||||
import { routeRoleRequirements } from '@/components/auth/ProtectedPageWrapper';
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { useTheme, useMediaQuery } from '@mui/material';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { styled, useColorScheme } from '@mui/material/styles';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
@ -19,6 +25,7 @@ import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import HttpIcon from '@mui/icons-material/Http';
|
||||
import AccountBoxIcon from '@mui/icons-material/AccountBox';
|
||||
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import TargetIcon from '@mui/icons-material/TrackChanges';
|
||||
@ -33,11 +40,11 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
|
||||
import { useTitle } from "@/context/TitleContext";
|
||||
|
||||
// Constants
|
||||
const drawerWidth = 240;
|
||||
@ -77,6 +84,61 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
|
||||
const { title } = useTitle();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Home', icon: <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 = () => {
|
||||
setOpen(true);
|
||||
@ -188,6 +250,29 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
</Select>
|
||||
</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>
|
||||
</AppBar>
|
||||
|
||||
@ -213,20 +298,21 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
</DrawerHeader>
|
||||
<Divider />
|
||||
<List>
|
||||
{[
|
||||
{ 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: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },
|
||||
{ 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: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //
|
||||
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },
|
||||
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
|
||||
].map((item) => (
|
||||
{/*{[*/}
|
||||
{/* { 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: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },*/}
|
||||
{/* { 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: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //*/}
|
||||
{/* { text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },*/}
|
||||
{/* { text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },*/}
|
||||
{/*].map((item) => (}*/}
|
||||
{visibleMenuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
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
|
||||
import React, { useEffect, ReactNode } from "react";
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from '@/components/auth/AuthContext';
|
||||
|
||||
import Layout from '@/components/layouts/Layout';
|
||||
import LayoutLogin from '@/components/layouts/LayoutLogin';
|
||||
@ -13,27 +14,52 @@ import BouncedEmails from '@/components/pages/BouncedEmails';
|
||||
import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls';
|
||||
import Templates from '@/components/pages/Templates';
|
||||
import EmailDomains from '@/components/pages/EmailDomains';
|
||||
import NewMailings from '@/components/pages/NewMailings';
|
||||
import AuthCheck from '@/components/auth/AuthCheck';
|
||||
|
||||
import { ColorModeContext } from '@/theme/theme';
|
||||
import { SetupDataProvider } from '@/context/SetupDataContext';
|
||||
import { useTitle } from "@/context/TitleContext";
|
||||
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import { createTheme, ThemeProvider, Theme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import ProtectedPageWrapper from '@/components/auth/ProtectedPageWrapper';
|
||||
|
||||
interface PageWrapperProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
const PageWrapper = ProtectedPageWrapper;
|
||||
|
||||
const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => {
|
||||
const { setTitle } = useTitle();
|
||||
//interface PageWrapperProps {
|
||||
// title: string;
|
||||
// children: ReactNode;
|
||||
//}
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(title);
|
||||
}, [title, setTitle]);
|
||||
//const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => {
|
||||
// const { setTitle } = useTitle();
|
||||
|
||||
return <>{children}</>;
|
||||
// useEffect(() => {
|
||||
// setTitle(title);
|
||||
// }, [title, setTitle]);
|
||||
|
||||
// return <>{children}</>;
|
||||
//};
|
||||
const getTheme = (mode: 'light' | 'dark'): Theme => {
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode, // Set the palette mode based on the parameter
|
||||
},
|
||||
cssVariables: {
|
||||
colorSchemeSelector: 'class',
|
||||
},
|
||||
colorSchemes: {
|
||||
light: true, // Default light scheme
|
||||
dark: true, // Default dark scheme
|
||||
},
|
||||
components: {
|
||||
MuiAutocomplete: {
|
||||
defaultProps: {
|
||||
handleHomeEndKeys: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
@ -48,15 +74,17 @@ const App = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
const theme = React.useMemo(() => createTheme({ palette: { mode } }), [mode]);
|
||||
const theme = React.useMemo(() => getTheme(mode), [mode]);
|
||||
return (
|
||||
<ColorModeContext.Provider value={colorMode}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SetupDataProvider>
|
||||
<AuthProvider>
|
||||
<Router basename="/">
|
||||
<AuthCheck />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
<Route
|
||||
path="/home"
|
||||
element={
|
||||
@ -147,6 +175,16 @@ const App = () => {
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/newMailings"
|
||||
element={
|
||||
<PageWrapper title="New Mailings">
|
||||
<Layout>
|
||||
<NewMailings />
|
||||
</Layout>
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
@ -157,6 +195,7 @@ const App = () => {
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</SetupDataProvider>
|
||||
</ThemeProvider>
|
||||
</ColorModeContext.Provider>
|
||||
|
||||
@ -1,80 +1,22 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import '@/css/main.css'
|
||||
import App from '@/components/pages/App'
|
||||
import '@/config/constants';
|
||||
import { TitleProvider } from "@/context/TitleContext";
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
|
||||
|
||||
//DEFAULT THEMES
|
||||
const theme = createTheme({
|
||||
cssVariables: {
|
||||
colorSchemeSelector: 'class'
|
||||
},
|
||||
colorSchemes: {
|
||||
light: true, // Default light scheme
|
||||
dark: true, // Default dark scheme
|
||||
},
|
||||
components: {
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
backgroundColor: theme.vars.palette.primary.main,
|
||||
color: theme.vars.palette.text.primary,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
notchedOutline: ({ theme }) => ({
|
||||
borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[500] : theme.vars.palette.text.primary,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[700] : theme.vars.palette.text.primary,
|
||||
},
|
||||
}),
|
||||
root: ({ theme }) => ({
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[900] : theme.vars.palette.text.primary,
|
||||
borderWidth: 2, // Match MUI default focus width
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
//CUSTOM THEMES
|
||||
//const theme = createTheme({
|
||||
// cssVariables: {
|
||||
// colorSchemeSelector: 'class'
|
||||
// },
|
||||
// colorSchemes: {
|
||||
// light: {
|
||||
// palette: {
|
||||
// primary: { main: '#1976d2' },
|
||||
// background: { default: '#fff', paper: '#f5f5f5' },
|
||||
// },
|
||||
// },
|
||||
// dark: {
|
||||
// palette: {
|
||||
// primary: { main: '#90caf9' },
|
||||
// background: { default: '#121212', paper: '#424242' },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
//});
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme} defaultMode="system">
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<TitleProvider>
|
||||
<App />
|
||||
</TitleProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
} else {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
@ -9,7 +9,7 @@ import {
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { AuthResponse, AuthErrorResponse, User, isAuthErrorResponse } from '@/types/auth';
|
||||
import utils from '@/ts/utils.ts';
|
||||
import utils from '@/ts/utils';
|
||||
//import ForgotPasswordModal from '@/components/modals/ForgotPasswordModal';
|
||||
|
||||
type SpinnerState = Record<string, boolean>;
|
||||
@ -78,6 +78,7 @@ function Login() {
|
||||
parameters: { username, password },
|
||||
success: (json: AuthResponse) => {
|
||||
try {
|
||||
localStorage.setItem('accessToken', json.accessToken);
|
||||
loggedInUser = json.user;
|
||||
} catch {
|
||||
const errorMsg: string = 'Unexpected Error';
|
||||
@ -117,6 +118,19 @@ function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { //Reset app settings to clear out prev login
|
||||
const resetAppSettings = async () => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('session_currentUser');
|
||||
//localStorage.clear();
|
||||
//sessionStorage.clear();
|
||||
|
||||
await fetch('/api/authentication/logout', { method: 'POST', credentials: 'include' });
|
||||
};
|
||||
|
||||
resetAppSettings();
|
||||
}, []);
|
||||
|
||||
const finishUserLogin = async (loggedInUser: User) => {
|
||||
setIsLoading(false);
|
||||
setSpinners({ Login: false, LoginWithPasskey: false });
|
||||
|
||||
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 = {
|
||||
isTokenExpired: (token: string): boolean => {
|
||||
const payload = JSON.parse(atob(token.split('.')[1])); // Decode JWT payload
|
||||
return payload.exp * 1000 < Date.now(); // Compare expiration (in ms) to current time
|
||||
},
|
||||
getUserRoles: (token: string): string[] => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'] || // Standard role claim
|
||||
payload.role || // Fallback for custom 'role' claim
|
||||
[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/*The following may not be needed any longer?
|
||||
**TODO: WebMethod should be changed to mimic fetch command but add in auth headers?
|
||||
fetch('/api/protected-endpoint', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
|
||||
}
|
||||
});
|
||||
|
||||
*/
|
||||
getCookie: (name: string): string | null => {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
@ -160,7 +184,7 @@ const utils = {
|
||||
},
|
||||
localStorageRemove: (key: string): void => {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
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.HOME}/.aspnet/https`;
|
||||
|
||||
const certificateName = "surge365.massemailreact.client";
|
||||
const certificateName = "Surge365.MassEmailReact.Web";
|
||||
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
|
||||
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user