Add target and server management features
- Implemented CreateTarget and updated UpdateTarget methods in TargetsController. - Introduced IServerService and IServerRepository for server operations. - Created ServersController for handling server-related API requests. - Added ServerUpdateDto for server updates and enhanced Target/Server classes with default values. - Updated TargetRepository and TargetService for target creation and updates. - Modified DapperConfiguration for server mappings. - Updated package dependencies for form validation and state management. - Enhanced Layout.tsx for improved responsiveness and user experience. - Added TitleContext for managing page titles. - Updated Servers.tsx and ServerEdit.tsx for server data handling. - Improved error handling and loading states across components.
This commit is contained in:
parent
4180e50c9c
commit
6f00235702
57
Surge365.MassEmailReact.API/Controllers/ServersController.cs
Normal file
57
Surge365.MassEmailReact.API/Controllers/ServersController.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Surge365.MassEmailReact.Application.DTOs;
|
||||||
|
using Surge365.MassEmailReact.Application.Interfaces;
|
||||||
|
using Surge365.MassEmailReact.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Surge365.MassEmailReact.Server.Controllers
|
||||||
|
{
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class ServersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IServerService _serverService;
|
||||||
|
|
||||||
|
public ServersController(IServerService serverService)
|
||||||
|
{
|
||||||
|
_serverService = serverService;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("GetAll")]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly, bool? returnPassword = null)
|
||||||
|
{
|
||||||
|
bool activeOnlyValue = activeOnly == null || activeOnly.Value ? true : false;
|
||||||
|
bool returnPasswordValue = returnPassword != null && returnPassword.Value ? true : false;
|
||||||
|
var servers = await _serverService.GetAllAsync(activeOnlyValue, returnPasswordValue);
|
||||||
|
return Ok(servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{key}")]
|
||||||
|
public async Task<IActionResult> GetByKey(int id, bool? returnPassword = null)
|
||||||
|
{
|
||||||
|
bool returnPasswordValue = returnPassword == null || returnPassword.Value ? true : false;
|
||||||
|
var server = await _serverService.GetByIdAsync(id, returnPasswordValue);
|
||||||
|
return server is not null ? Ok(server) : NotFound($"Server with key '{id}' not found.");
|
||||||
|
}
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> UpdateServer(int id, [FromBody] ServerUpdateDto serverUpdateDto)
|
||||||
|
{
|
||||||
|
if (id != serverUpdateDto.Id)
|
||||||
|
return BadRequest("ID in URL does not match ID in request body");
|
||||||
|
|
||||||
|
var existingServer = await _serverService.GetByIdAsync(id);
|
||||||
|
if (existingServer == null)
|
||||||
|
return NotFound($"Server with ID {id} not found");
|
||||||
|
|
||||||
|
var success = await _serverService.UpdateAsync(serverUpdateDto);
|
||||||
|
if (!success)
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update server.");
|
||||||
|
|
||||||
|
var updatedServer = await _serverService.GetByIdAsync(id);
|
||||||
|
|
||||||
|
return Ok(updatedServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,15 +32,29 @@ namespace Surge365.MassEmailReact.Server.Controllers
|
|||||||
var target = await _targetService.GetByIdAsync(id);
|
var target = await _targetService.GetByIdAsync(id);
|
||||||
return target is not null ? Ok(target) : NotFound($"Target with key '{id}' not found.");
|
return target is not null ? Ok(target) : NotFound($"Target with key '{id}' not found.");
|
||||||
}
|
}
|
||||||
|
[HttpPost()]
|
||||||
|
public async Task<IActionResult> CreateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
|
||||||
|
{
|
||||||
|
if (targetUpdateDto.Id != null && targetUpdateDto.Id > 0)
|
||||||
|
return BadRequest("Id must be null or 0");
|
||||||
|
|
||||||
|
var targetId = await _targetService.CreateAsync(targetUpdateDto);
|
||||||
|
if (targetId == null)
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to craete target.");
|
||||||
|
|
||||||
|
var createdTarget = await _targetService.GetByIdAsync(targetId.Value);
|
||||||
|
|
||||||
|
return Ok(createdTarget);
|
||||||
|
}
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> UpdateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
|
public async Task<IActionResult> UpdateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
|
||||||
{
|
{
|
||||||
if (id != targetUpdateDto.Id)
|
if (id != targetUpdateDto.Id)
|
||||||
return BadRequest("ID in URL does not match ID in request body");
|
return BadRequest("Id in URL does not match Id in request body");
|
||||||
|
|
||||||
var existingTarget = await _targetService.GetByIdAsync(id);
|
var existingTarget = await _targetService.GetByIdAsync(id);
|
||||||
if (existingTarget == null)
|
if (existingTarget == null)
|
||||||
return NotFound($"Target with ID {id} not found");
|
return NotFound($"Target with Id {id} not found");
|
||||||
|
|
||||||
var success = await _targetService.UpdateAsync(targetUpdateDto);
|
var success = await _targetService.UpdateAsync(targetUpdateDto);
|
||||||
if (!success)
|
if (!success)
|
||||||
|
|||||||
@ -16,6 +16,8 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<ITargetService, TargetService>();
|
builder.Services.AddScoped<ITargetService, TargetService>();
|
||||||
builder.Services.AddScoped<ITargetRepository, TargetRepository>();
|
builder.Services.AddScoped<ITargetRepository, TargetRepository>();
|
||||||
|
builder.Services.AddScoped<IServerService, ServerService>();
|
||||||
|
builder.Services.AddScoped<IServerRepository, ServerRepository>();
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
|
|||||||
18
Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs
Normal file
18
Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Surge365.MassEmailReact.Domain.Entities
|
||||||
|
{
|
||||||
|
public class ServerUpdateDto
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string ServerName { get; set; } = "";
|
||||||
|
public short Port { get; set; }
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
using Surge365.MassEmailReact.Domain.Entities;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Surge365.MassEmailReact.Application.Interfaces
|
||||||
|
{
|
||||||
|
public interface IServerRepository
|
||||||
|
{
|
||||||
|
Task<Server?> GetByIdAsync(int id, bool returnPassword = false);
|
||||||
|
Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false);
|
||||||
|
Task<bool> UpdateAsync(Server server);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
using Surge365.MassEmailReact.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Surge365.MassEmailReact.Application.Interfaces
|
||||||
|
{
|
||||||
|
public interface IServerService
|
||||||
|
{
|
||||||
|
Task<Server?> GetByIdAsync(int id, bool returnPassword = false);
|
||||||
|
Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false);
|
||||||
|
Task<bool> UpdateAsync(ServerUpdateDto serverDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
|||||||
{
|
{
|
||||||
Task<Target?> GetByIdAsync(int id);
|
Task<Target?> GetByIdAsync(int id);
|
||||||
Task<List<Target>> GetAllAsync(bool activeOnly = true);
|
Task<List<Target>> GetAllAsync(bool activeOnly = true);
|
||||||
|
Task<int?> CreateAsync(Target target);
|
||||||
Task<bool> UpdateAsync(Target target);
|
Task<bool> UpdateAsync(Target target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
|||||||
{
|
{
|
||||||
Task<Target?> GetByIdAsync(int id);
|
Task<Target?> GetByIdAsync(int id);
|
||||||
Task<List<Target>> GetAllAsync(bool activeOnly = true);
|
Task<List<Target>> GetAllAsync(bool activeOnly = true);
|
||||||
|
Task<int?> CreateAsync(TargetUpdateDto targetDto);
|
||||||
Task<bool> UpdateAsync(TargetUpdateDto targetDto);
|
Task<bool> UpdateAsync(TargetUpdateDto targetDto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
Surge365.MassEmailReact.Domain/Entities/Server.cs
Normal file
20
Surge365.MassEmailReact.Domain/Entities/Server.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Surge365.MassEmailReact.Domain.Entities
|
||||||
|
{
|
||||||
|
public class Server
|
||||||
|
{
|
||||||
|
public int? Id { get; private set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string ServerName { get; set; } = "";
|
||||||
|
public short Port { get; set; }
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
|
||||||
|
public Server() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,10 +10,10 @@ namespace Surge365.MassEmailReact.Domain.Entities
|
|||||||
{
|
{
|
||||||
public int? Id { get; private set; }
|
public int? Id { get; private set; }
|
||||||
public int ServerId { get; set; }
|
public int ServerId { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; } = "";
|
||||||
public string DatabaseName { get; set; }
|
public string DatabaseName { get; set; } = "";
|
||||||
public string ViewName { get; set; }
|
public string ViewName { get; set; } = "";
|
||||||
public string FilterQuery { get; set; }
|
public string FilterQuery { get; set; } = "";
|
||||||
public bool AllowWriteBack { get; set; }
|
public bool AllowWriteBack { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
|
|||||||
FluentMapper.Initialize(config =>
|
FluentMapper.Initialize(config =>
|
||||||
{
|
{
|
||||||
config.AddMap(new TargetMap());
|
config.AddMap(new TargetMap());
|
||||||
|
config.AddMap(new ServerMap());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
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 ServerMap : EntityMap<Server>
|
||||||
|
{
|
||||||
|
public ServerMap()
|
||||||
|
{
|
||||||
|
Map(t => t.Id).ToColumn("server_key");
|
||||||
|
Map(t => t.Name).ToColumn("name");
|
||||||
|
Map(t => t.ServerName).ToColumn("server_name");
|
||||||
|
Map(t => t.Port).ToColumn("port");
|
||||||
|
Map(t => t.Username).ToColumn("username");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Dapper.FluentMap;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Surge365.MassEmailReact.Application.Interfaces;
|
||||||
|
using Surge365.MassEmailReact.Domain.Entities;
|
||||||
|
using Surge365.MassEmailReact.Domain.Enums;
|
||||||
|
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 ServerRepository : IServerRepository
|
||||||
|
{
|
||||||
|
private IConfiguration _config;
|
||||||
|
private const string _connectionStringName = "MassEmail.ConnectionString";
|
||||||
|
private string? ConnectionString
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return _config.GetConnectionString(_connectionStringName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public ServerRepository(IConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
#if DEBUG
|
||||||
|
if (!FluentMapper.EntityMaps.ContainsKey(typeof(Server)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Server dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
public async Task<Server?> GetByIdAsync(int serverKey, bool returnPassword = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(_config);
|
||||||
|
ArgumentNullException.ThrowIfNull(_connectionStringName);
|
||||||
|
|
||||||
|
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||||
|
|
||||||
|
Server? server = (await conn.QueryAsync<Server>("mem_get_server_by_id", new { server_key = serverKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
|
||||||
|
if (server != null && !returnPassword)
|
||||||
|
{
|
||||||
|
server.Password = "";
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
public async Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(_config);
|
||||||
|
ArgumentNullException.ThrowIfNull(_connectionStringName);
|
||||||
|
|
||||||
|
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||||
|
|
||||||
|
List<Server> servers = (await conn.QueryAsync<Server>("mem_get_server_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
|
||||||
|
if (!returnPassword)
|
||||||
|
{
|
||||||
|
foreach (Server server in servers)
|
||||||
|
server.Password = "";
|
||||||
|
}
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateAsync(Server server)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(server);
|
||||||
|
ArgumentNullException.ThrowIfNull(server.Id);
|
||||||
|
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||||
|
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("@server_key", server.Id, DbType.Int32);
|
||||||
|
parameters.Add("@name", server.Name, DbType.String);
|
||||||
|
parameters.Add("@server_name", server.ServerName, DbType.String);
|
||||||
|
parameters.Add("@port", server.Port, DbType.Int32);
|
||||||
|
parameters.Add("@username", server.Username, DbType.String);
|
||||||
|
parameters.Add("@password", server.Password, DbType.String);
|
||||||
|
|
||||||
|
// Output parameter
|
||||||
|
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
|
||||||
|
|
||||||
|
await conn.ExecuteAsync("mem_save_server", parameters, commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
// Retrieve the output parameter value
|
||||||
|
bool success = parameters.Get<bool>("@success");
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
//public void Add(Server server)
|
||||||
|
//{
|
||||||
|
// Servers.Add(server);
|
||||||
|
//}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,37 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
|||||||
//return conn.Query<Target>("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList();
|
//return conn.Query<Target>("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int?> CreateAsync(Target target)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
if (target.Id != null && target.Id > 0)
|
||||||
|
throw new Exception("ID must be null");
|
||||||
|
|
||||||
|
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||||
|
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("@target_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
|
||||||
|
parameters.Add("@server_key", target.ServerId, DbType.Int32);
|
||||||
|
parameters.Add("@name", target.Name, DbType.String);
|
||||||
|
parameters.Add("@database_name", target.DatabaseName, DbType.String);
|
||||||
|
parameters.Add("@view_name", target.ViewName, DbType.String);
|
||||||
|
parameters.Add("@filter_query", target.FilterQuery, DbType.String);
|
||||||
|
parameters.Add("@allow_write_back", target.AllowWriteBack, DbType.Boolean);
|
||||||
|
parameters.Add("@is_active", target.IsActive, DbType.Boolean);
|
||||||
|
|
||||||
|
// Output parameter
|
||||||
|
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
|
||||||
|
|
||||||
|
await conn.ExecuteAsync("mem_save_target", parameters, commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
|
// Retrieve the output parameter value
|
||||||
|
bool success = parameters.Get<bool>("@success");
|
||||||
|
|
||||||
|
if(success)
|
||||||
|
return parameters.Get<int>("@target_key");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
public async Task<bool> UpdateAsync(Target target)
|
public async Task<bool> UpdateAsync(Target target)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(target);
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
|||||||
@ -101,7 +101,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
|||||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all");
|
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all");
|
||||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||||
return null;
|
throw new Exception("No users returned");
|
||||||
|
|
||||||
return LoadFromDataRow(ds.Tables[0]);
|
return LoadFromDataRow(ds.Tables[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
using Surge365.MassEmailReact.Application.Interfaces;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Surge365.MassEmailReact.Domain.Entities;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
|
||||||
|
namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||||
|
{
|
||||||
|
public class ServerService : IServerService
|
||||||
|
{
|
||||||
|
private readonly IServerRepository _serverRepository;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
|
||||||
|
public ServerService(IServerRepository serverRepository, IConfiguration config)
|
||||||
|
{
|
||||||
|
_serverRepository = serverRepository;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Server?> GetByIdAsync(int id, bool returnPassword = false)
|
||||||
|
{
|
||||||
|
return await _serverRepository.GetByIdAsync(id, returnPassword);
|
||||||
|
}
|
||||||
|
public async Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false)
|
||||||
|
{
|
||||||
|
return await _serverRepository.GetAllAsync(activeOnly, returnPassword);
|
||||||
|
}
|
||||||
|
public async Task<bool> UpdateAsync(ServerUpdateDto serverDto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(serverDto, nameof(serverDto));
|
||||||
|
ArgumentNullException.ThrowIfNull(serverDto.Id, nameof(serverDto.Id));
|
||||||
|
|
||||||
|
var server = await _serverRepository.GetByIdAsync(serverDto.Id.Value);
|
||||||
|
if (server == null || server.Id == null) return false;
|
||||||
|
|
||||||
|
server.Name = serverDto.Name;
|
||||||
|
server.ServerName = serverDto.ServerName;
|
||||||
|
server.Port = serverDto.Port;
|
||||||
|
server.Username = serverDto.Username;
|
||||||
|
server.Password = serverDto.Password;
|
||||||
|
|
||||||
|
return await _serverRepository.UpdateAsync(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
|||||||
{
|
{
|
||||||
return await _targetRepository.GetAllAsync(activeOnly);
|
return await _targetRepository.GetAllAsync(activeOnly);
|
||||||
}
|
}
|
||||||
|
public async Task<int?> CreateAsync(TargetUpdateDto targetDto)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
|
||||||
|
if (targetDto.Id != null && targetDto.Id > 0)
|
||||||
|
throw new Exception("ID must be null");
|
||||||
|
|
||||||
|
var target = new Target();
|
||||||
|
|
||||||
|
target.ServerId = targetDto.ServerId;
|
||||||
|
target.Name = targetDto.Name;
|
||||||
|
target.DatabaseName = targetDto.DatabaseName;
|
||||||
|
target.ViewName = targetDto.ViewName;
|
||||||
|
target.FilterQuery = targetDto.FilterQuery;
|
||||||
|
target.AllowWriteBack = targetDto.AllowWriteBack;
|
||||||
|
target.IsActive = targetDto.IsActive;
|
||||||
|
|
||||||
|
return await _targetRepository.CreateAsync(target);
|
||||||
|
}
|
||||||
public async Task<bool> UpdateAsync(TargetUpdateDto targetDto)
|
public async Task<bool> UpdateAsync(TargetUpdateDto targetDto)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
|
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
|
||||||
|
|||||||
81
Surge365.MassEmailReact.Web/package-lock.json
generated
81
Surge365.MassEmailReact.Web/package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/roboto": "^5.1.1",
|
"@fontsource/roboto": "^5.1.1",
|
||||||
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@mui/icons-material": "^6.4.5",
|
"@mui/icons-material": "^6.4.5",
|
||||||
"@mui/material": "^6.4.5",
|
"@mui/material": "^6.4.5",
|
||||||
"@mui/x-charts": "^7.27.1",
|
"@mui/x-charts": "^7.27.1",
|
||||||
@ -23,8 +24,10 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-bootstrap": "^2.10.9",
|
"react-bootstrap": "^2.10.9",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-router-dom": "^7.0.1"
|
"react-router-dom": "^7.0.1",
|
||||||
|
"yup": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
@ -1063,6 +1066,18 @@
|
|||||||
"integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==",
|
"integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-wl6H9c9wLOZMJAqGLEVKzbCkxJuV+BYuLFZFCQtCwMe0b3qQk4kUBd/ZAj13SwcSqcx86rCgSCyngQfmA6DOWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/utils": "^0.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@ -1990,6 +2005,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@stencil/core": {
|
"node_modules/@stencil/core": {
|
||||||
"version": "4.26.0",
|
"version": "4.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.26.0.tgz",
|
||||||
@ -3959,6 +3980,12 @@
|
|||||||
"react": ">=0.14.0"
|
"react": ">=0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/property-expr": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@ -4042,6 +4069,22 @@
|
|||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.54.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
|
||||||
|
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-icons": {
|
"node_modules/react-icons": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
@ -4358,6 +4401,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-case": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@ -4371,6 +4420,12 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toposort": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz",
|
||||||
@ -4409,6 +4464,18 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "2.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||||
|
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.7.3",
|
"version": "5.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||||
@ -4659,6 +4726,18 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yup": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"property-expr": "^2.0.5",
|
||||||
|
"tiny-case": "^1.0.3",
|
||||||
|
"toposort": "^2.0.2",
|
||||||
|
"type-fest": "^2.19.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@fontsource/roboto": "^5.1.1",
|
"@fontsource/roboto": "^5.1.1",
|
||||||
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@mui/icons-material": "^6.4.5",
|
"@mui/icons-material": "^6.4.5",
|
||||||
"@mui/material": "^6.4.5",
|
"@mui/material": "^6.4.5",
|
||||||
"@mui/x-charts": "^7.27.1",
|
"@mui/x-charts": "^7.27.1",
|
||||||
@ -26,8 +27,10 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-bootstrap": "^2.10.9",
|
"react-bootstrap": "^2.10.9",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-router-dom": "^7.0.1"
|
"react-router-dom": "^7.0.1",
|
||||||
|
"yup": "^1.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
// src/components/layouts/Layout.tsx
|
// src/components/layouts/Layout.tsx
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
|
import { useTheme, useMediaQuery } from '@mui/material';
|
||||||
|
|
||||||
import { styled, useColorScheme } from '@mui/material/styles';
|
import { styled, useColorScheme } from '@mui/material/styles';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Drawer from '@mui/material/Drawer';
|
import Drawer from '@mui/material/Drawer';
|
||||||
@ -16,14 +18,26 @@ import ListItemButton from '@mui/material/ListItemButton';
|
|||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
|
|
||||||
import PeopleIcon from '@mui/icons-material/People';
|
import DnsIcon from '@mui/icons-material/Dns';
|
||||||
|
import TargetIcon from '@mui/icons-material/TrackChanges';
|
||||||
|
import MarkEmailReadIcon from '@mui/icons-material/MarkEmailRead';
|
||||||
|
import BlockIcon from '@mui/icons-material/Block';
|
||||||
|
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
||||||
|
import EmailIcon from '@mui/icons-material/Email';
|
||||||
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
|
import ScheduleSendIcon from '@mui/icons-material/ScheduleSend';
|
||||||
|
import AutorenewIcon from '@mui/icons-material/Autorenew';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import InputLabel from '@mui/material/InputLabel';
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
|
||||||
|
import { useTitle } from "@/context/TitleContext";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
|
|
||||||
@ -56,26 +70,26 @@ interface LayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Layout = ({ children }: LayoutProps) => {
|
const Layout = ({ children }: LayoutProps) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(true);
|
||||||
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
|
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
|
||||||
const iconButtonRef = React.useRef<HTMLButtonElement>(null)
|
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
|
||||||
|
const { title } = useTitle();
|
||||||
|
|
||||||
const handleDrawerOpen = () => {
|
const handleDrawerOpen = () => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
sendResize();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrawerClose = () => {
|
const handleDrawerClose = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
sendResize();
|
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
const sendResize = () => {
|
|
||||||
//// Force window resize event after drawer state changes
|
|
||||||
//setTimeout(() => {
|
|
||||||
// window.dispatchEvent(new Event("resize"));
|
|
||||||
//}); // Delay slightly to ensure UI updates
|
|
||||||
};
|
|
||||||
const handleThemeChange = (event: SelectChangeEvent) => {
|
const handleThemeChange = (event: SelectChangeEvent) => {
|
||||||
setMode(event.target.value as 'light' | 'dark');
|
setMode(event.target.value as 'light' | 'dark');
|
||||||
if (iconButtonRef.current) {
|
if (iconButtonRef.current) {
|
||||||
@ -102,7 +116,7 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
duration: theme.transitions.duration.leavingScreen,
|
duration: theme.transitions.duration.leavingScreen,
|
||||||
}),
|
}),
|
||||||
...(open && {
|
...(open && {
|
||||||
width: `calc(100% - ${drawerWidth}px)`,
|
width: isMobile ? "100%" : `calc(100% - ${drawerWidth}px)`,
|
||||||
ml: `${drawerWidth}px`,
|
ml: `${drawerWidth}px`,
|
||||||
transition: theme.transitions.create(['width', 'margin', 'padding'], {
|
transition: theme.transitions.create(['width', 'margin', 'padding'], {
|
||||||
easing: theme.transitions.easing.easeInOut,
|
easing: theme.transitions.easing.easeInOut,
|
||||||
@ -112,6 +126,17 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
|
{isMobile && open ?
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="close drawer"
|
||||||
|
onClick={handleDrawerClose}
|
||||||
|
edge="start"
|
||||||
|
ref={iconButtonRef}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</IconButton> :
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label="open drawer"
|
aria-label="open drawer"
|
||||||
@ -122,8 +147,9 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
}
|
||||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
Surge365 Dashboard
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl sx={{ minWidth: 120 }} size="small">
|
<FormControl sx={{ minWidth: 120 }} size="small">
|
||||||
<InputLabel
|
<InputLabel
|
||||||
@ -145,14 +171,14 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
color: 'white', // White text
|
color: 'white', // White text
|
||||||
'& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow
|
'& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'white', // Gray in light, white in dark
|
borderColor: 'white',
|
||||||
},
|
},
|
||||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'white', // Darker gray on hover in light
|
borderColor: 'white',
|
||||||
borderWidth: 2
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'white', // Even darker gray when focused in light
|
borderColor: 'white',
|
||||||
borderWidth: 2
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@ -166,18 +192,19 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="persistent"
|
variant={isMobile ? "temporary" : "persistent"}
|
||||||
anchor="left"
|
anchor="left"
|
||||||
open={open}
|
open={open}
|
||||||
sx={{
|
sx={{
|
||||||
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'& .MuiDrawer-paper': {
|
'& .MuiDrawer-paper': {
|
||||||
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<IconButton onClick={handleDrawerClose}>
|
<IconButton onClick={handleDrawerClose}>
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
@ -187,8 +214,16 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
<List>
|
<List>
|
||||||
{[
|
{[
|
||||||
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
|
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
|
||||||
{ text: 'Targets', icon: <DirectionsCarIcon />, path: '/targets' },
|
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' },
|
||||||
{ text: 'Templates', icon: <PeopleIcon />, path: '/templates' },
|
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' },
|
||||||
|
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testLists' },
|
||||||
|
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },
|
||||||
|
{ 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) => (
|
].map((item) => (
|
||||||
<ListItem key={item.text} disablePadding>
|
<ListItem key={item.text} disablePadding>
|
||||||
<ListItemButton component={RouterLink} to={item.path}>
|
<ListItemButton component={RouterLink} to={item.path}>
|
||||||
|
|||||||
107
Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx
Normal file
107
Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
TextField,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import Server from "@/types/server";
|
||||||
|
|
||||||
|
type ServerEditProps = {
|
||||||
|
open: boolean;
|
||||||
|
server: Server;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (updatedServer: Server) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerEdit = ({ open, server, onClose, onSave }: ServerEditProps) => {
|
||||||
|
const [formData, setFormData] = useState<Server>({ ...server });
|
||||||
|
//const [serverError, setServerError] = useState(false); // Track validation
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { //Reset form to unedited state on open or server change
|
||||||
|
if (open) {
|
||||||
|
setFormData(server);
|
||||||
|
}
|
||||||
|
}, [open, server]);
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value || "" }));
|
||||||
|
|
||||||
|
//if (field === "serverId" && value) setServerError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${formData.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to update");
|
||||||
|
|
||||||
|
const updatedServer = await response.json();
|
||||||
|
onSave(updatedServer); // Update UI optimistically
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Edit Server - id={formData.id}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
fullWidth
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange("name", e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Server Name"
|
||||||
|
fullWidth
|
||||||
|
value={formData.serverName}
|
||||||
|
onChange={(e) => handleChange("serverName", e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Port"
|
||||||
|
fullWidth
|
||||||
|
value={formData.port}
|
||||||
|
onChange={(e) => handleChange("port", e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
fullWidth
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => handleChange("username", e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
fullWidth
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleChange("password", e.target.value)}
|
||||||
|
margin="dense"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} color="primary" disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerEdit;
|
||||||
@ -1,44 +1,105 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
TextField,
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Button,
|
Button,
|
||||||
Switch,
|
Switch,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Target } from "@/types/target";
|
import Target from "@/types/target";
|
||||||
|
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";
|
||||||
|
|
||||||
type TargetEditProps = {
|
type TargetEditProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
target: Target;
|
target: Target | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (updatedTarget: Target) => void;
|
onSave: (updatedTarget: Target) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
|
const schema = yup.object().shape({
|
||||||
const [formData, setFormData] = useState<Target>({ ...target });
|
id: yup.number(),
|
||||||
const [loading, setLoading] = useState(false);
|
serverId: yup.number().typeError("Server is required").required("Server is required").moreThan(0, "Server is required"),
|
||||||
|
name: yup
|
||||||
|
.string()
|
||||||
|
.required("Name is required")
|
||||||
|
.test("unique-name", "Name must be unique", function (value) {
|
||||||
|
const setupData = this.options.context?.setupData as { targets: Target[] };
|
||||||
|
if (!setupData) return true;
|
||||||
|
|
||||||
const handleChange = (field: keyof Target, value: string | boolean) => {
|
return !setupData.targets.some(
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
(t) => t.name.toLowerCase() === value?.toLowerCase() && (t.id === 0 || t.id !== this.parent.id)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
databaseName: yup.string().required("Database name is required"),
|
||||||
|
viewName: yup.string().required("View name is required"),
|
||||||
|
filterQuery: yup.string().nullable(),
|
||||||
|
allowWriteBack: yup.boolean().default(false),
|
||||||
|
isActive: yup.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
//TODO: Make DatabaseName a select using new array in setupData.servers.databases
|
||||||
|
//TODO: Maybe Make View a select using new array in setupData.servers.databases.views+procs. But would have to allow free form entry just in case/no validation on found in select
|
||||||
|
//TODO: Add verify/test button on form, checks that server, db, view exist, query works and returns data. Show sample data on screen.
|
||||||
|
const defaultTarget: Target = {
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
serverId: 0,
|
||||||
|
databaseName: "",
|
||||||
|
viewName: "",
|
||||||
|
filterQuery: "",
|
||||||
|
allowWriteBack: false,
|
||||||
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
|
||||||
|
const isNew = !target || target.id === 0;
|
||||||
|
const setupData: SetupData = useSetupData();
|
||||||
|
|
||||||
|
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Target>({
|
||||||
|
mode: "onBlur",
|
||||||
|
defaultValues: target || defaultTarget,
|
||||||
|
resolver: yupResolver(schema) as Resolver<Target>,
|
||||||
|
context: { setupData }
|
||||||
|
,
|
||||||
|
});
|
||||||
|
//const [formData, setFormData] = useState<Target>(target ? { ...target } : { ...defaultTarget });
|
||||||
|
//const [serverError, setServerError] = useState(false); // Track validation
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { //Reset form to unedited state on open or target change
|
||||||
|
if (open) {
|
||||||
|
reset(target || defaultTarget, { keepDefaultValues: true });
|
||||||
|
}
|
||||||
|
}, [open, target, reset]);
|
||||||
|
|
||||||
|
//const handleChange = (field: string, value: any) => {
|
||||||
|
// setFormData((prev) => ({ ...prev, [field]: value?.id || "" }));
|
||||||
|
|
||||||
|
// if (field === "serverId" && value) setServerError(false);
|
||||||
|
//};
|
||||||
|
|
||||||
|
const handleSave = async (formData: Target) => {
|
||||||
|
const apiUrl = isNew ? "/api/targets" : `/api/targets/${formData.id}`;
|
||||||
|
const method = isNew ? "POST" : "PUT";
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/targets/${formData.id}`, {
|
const response = await fetch(apiUrl, {
|
||||||
method: "PUT",
|
method: method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(formData),
|
body: JSON.stringify(formData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Failed to update");
|
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
|
||||||
|
|
||||||
const updatedTarget = await response.json();
|
const updatedTarget = await response.json();
|
||||||
onSave(updatedTarget); // Update UI optimistically
|
onSave(updatedTarget);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update error:", error);
|
console.error("Update error:", error);
|
||||||
@ -49,74 +110,98 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>Edit Target</DialogTitle>
|
<DialogTitle>{isNew ? "Add Target" : "Edit Target id=" + target.id}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<Controller
|
||||||
|
name="serverId"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: "Server is required" }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete {...field}
|
||||||
|
options={setupData.servers}
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
value={setupData.servers.find((s) => s.id === Number(field.value)) || null}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
field.onChange(newValue ? newValue.id : null);
|
||||||
|
trigger("serverId");
|
||||||
|
}}
|
||||||
|
|
||||||
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField
|
||||||
label="Target Key"
|
{...params}
|
||||||
|
label="Server"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={formData.id}
|
|
||||||
onChange={(e) => handleChange("id", e.target.value)}
|
|
||||||
margin="dense"
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Server Key"
|
|
||||||
fullWidth
|
|
||||||
value={formData.serverId}
|
|
||||||
onChange={(e) => handleChange("serverId", e.target.value)}
|
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
error={!!errors.serverId}
|
||||||
|
helperText={errors.serverId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
{...register("name")}
|
||||||
label="Name"
|
label="Name"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleChange("name", e.target.value)}
|
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors.name?.message}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
{...register("databaseName")}
|
||||||
label="Database Name"
|
label="Database Name"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={formData.databaseName}
|
|
||||||
onChange={(e) => handleChange("databaseName", e.target.value)}
|
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
error={!!errors.databaseName}
|
||||||
|
helperText={errors.databaseName?.message}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
{...register("viewName")}
|
||||||
label="View Name"
|
label="View Name"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={formData.viewName}
|
|
||||||
onChange={(e) => handleChange("viewName", e.target.value)}
|
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
error={!!errors.viewName}
|
||||||
|
helperText={errors.viewName?.message}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
{...register("filterQuery")}
|
||||||
label="Filter Query"
|
label="Filter Query"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={formData.filterQuery}
|
|
||||||
onChange={(e) => handleChange("filterQuery", e.target.value)}
|
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
error={!!errors.filterQuery}
|
||||||
|
helperText={errors.filterQuery?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={formData.allowWriteBack}
|
{...register("allowWriteBack")}
|
||||||
onChange={(e) => handleChange("allowWriteBack", e.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Allow Write Back"
|
label="Allow Write Back"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="isActive"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={formData.isActive}
|
{...field}
|
||||||
onChange={(e) => handleChange("isActive", e.target.checked)}
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Active"
|
label="Active"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||||
<Button onClick={handleSave} color="primary" disabled={loading}>
|
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
|
||||||
{loading ? "Saving..." : "Save"}
|
{loading ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@ -1,18 +1,35 @@
|
|||||||
// App.tsx or main routing component
|
// App.tsx or main routing component
|
||||||
import React from 'react';
|
import React, { useEffect, ReactNode } from "react";
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import Layout from '@/components/layouts/Layout';
|
import Layout from '@/components/layouts/Layout';
|
||||||
import LayoutLogin from '@/components/layouts/LayoutLogin';
|
import LayoutLogin from '@/components/layouts/LayoutLogin';
|
||||||
import Home from '@/components/pages/Home';
|
import Home from '@/components/pages/Home';
|
||||||
import Login from '@/components/pages/Login';
|
import Login from '@/components/pages/Login';
|
||||||
|
import Servers from '@/components/pages/Servers';
|
||||||
import Targets from '@/components/pages/Targets';
|
import Targets from '@/components/pages/Targets';
|
||||||
import Templates from '@/components/pages/Templates';
|
import Templates from '@/components/pages/Templates';
|
||||||
import { ColorModeContext } from '@/theme/theme';
|
import { ColorModeContext } from '@/theme/theme';
|
||||||
import { SetupDataProvider } from '@/context/SetupDataContext';
|
import { SetupDataProvider } from '@/context/SetupDataContext';
|
||||||
|
import { useTitle } from "@/context/TitleContext";
|
||||||
|
|
||||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
|
||||||
|
interface PageWrapperProps {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => {
|
||||||
|
const { setTitle } = useTitle();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(title);
|
||||||
|
}, [title, setTitle]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
|
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
|
||||||
@ -38,25 +55,41 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/home"
|
path="/home"
|
||||||
element={
|
element={
|
||||||
|
<PageWrapper title="Dashboard">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Home />
|
<Home />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/servers"
|
||||||
|
element={
|
||||||
|
<PageWrapper title="Servers">
|
||||||
|
<Layout>
|
||||||
|
<Servers />
|
||||||
|
</Layout>
|
||||||
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/targets"
|
path="/targets"
|
||||||
element={
|
element={
|
||||||
|
<PageWrapper title="Targets">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Targets />
|
<Targets />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/templates"
|
path="/templates"
|
||||||
element={
|
element={
|
||||||
|
<PageWrapper title="Templates">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Templates />
|
<Templates />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
|
|||||||
import '@/css/main.css'
|
import '@/css/main.css'
|
||||||
import App from '@/components/pages/App'
|
import App from '@/components/pages/App'
|
||||||
import '@/config/constants';
|
import '@/config/constants';
|
||||||
|
import { TitleProvider } from "@/context/TitleContext";
|
||||||
|
|
||||||
|
|
||||||
//DEFAULT THEMES
|
//DEFAULT THEMES
|
||||||
@ -70,7 +71,9 @@ if (rootElement) {
|
|||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider theme={theme} defaultMode="system">
|
<ThemeProvider theme={theme} defaultMode="system">
|
||||||
|
<TitleProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</TitleProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
170
Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx
Normal file
170
Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useSetupData, SetupData } from "@/context/SetupDataContext";
|
||||||
|
//import Typography from '@mui/material/Typography';
|
||||||
|
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
|
||||||
|
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
|
||||||
|
import { Lock, LockOpen } from "@mui/icons-material";
|
||||||
|
//import utils from '@/ts/utils';
|
||||||
|
import Server from '@/types/server';
|
||||||
|
import ServerEdit from "@/components/modals/ServerEdit";
|
||||||
|
|
||||||
|
|
||||||
|
function Servers() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const setupData: SetupData = useSetupData();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
|
||||||
|
const [servers, setServers] = useState<Server[] | null>(null);
|
||||||
|
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [selectedRow, setSelectedRow] = useState<Server | null>(null);
|
||||||
|
const [open, setOpen] = useState<boolean>(false);
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const togglePasswordVisibility = async () => {
|
||||||
|
if (isPasswordVisible) {
|
||||||
|
setIsPasswordVisible(false);
|
||||||
|
setServers(setupData.servers);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
setIsPasswordVisible(true);
|
||||||
|
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=true");
|
||||||
|
const serversData = await serversResponse.json();
|
||||||
|
setServers(serversData);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Error fetching servers:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: GridColDef<Server>[] = [
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Actions",
|
||||||
|
sortable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams<Server>) => (
|
||||||
|
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ field: "id", headerName: "ID", width: 60 },
|
||||||
|
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
|
||||||
|
{ field: "serverName", headerName: "Database", flex: 1, minWidth: 160 },
|
||||||
|
{ field: "port", headerName: "Port", width: 75 },
|
||||||
|
{ field: "username", headerName: "Username", flex: 1, minWidth: 100 },
|
||||||
|
{
|
||||||
|
field: "password",
|
||||||
|
headerName: "Password",
|
||||||
|
width: 175,
|
||||||
|
renderHeader: () => (
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
Password
|
||||||
|
<IconButton size="small" onClick={togglePasswordVisibility} sx={{ marginLeft: 1 }}>
|
||||||
|
{isPasswordVisible ? <LockOpen /> : <Lock />}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
renderCell: (params: GridRenderCellParams<Server>) =>
|
||||||
|
isPasswordVisible ? params.value : "••••••••",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const handleEdit = (row: GridRowModel<Server>) => {
|
||||||
|
setSelectedRow(row);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRow = (updatedRow: Server) => {
|
||||||
|
setupData.setServers(updatedRow);
|
||||||
|
updateServers(updatedRow);
|
||||||
|
};
|
||||||
|
const updateServers = (updatedServer: Server) => {
|
||||||
|
setServers((prevServers) => {
|
||||||
|
if (prevServers == null) return null;
|
||||||
|
return prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
{ (!isPasswordVisible ? setupData.servers : servers)?.map((row) => (
|
||||||
|
<Card key={row.id} sx={{ marginBottom: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">{row.name}</Typography>
|
||||||
|
<Typography variant="body2">Name: {row.name}</Typography>
|
||||||
|
<Typography variant="body2">Server Name: {row.serverName}</Typography>
|
||||||
|
<Typography variant="body2">Port: {row.port}</Typography>
|
||||||
|
<Typography variant="body2">Username: {row.username}</Typography>
|
||||||
|
<Typography variant="body2">Password: {row.password}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
rows={(!isPasswordVisible ? setupData.servers : servers)!}
|
||||||
|
columns={columns}
|
||||||
|
autoPageSize
|
||||||
|
sx={{ minWidth: "600px" }}
|
||||||
|
//getRowId={(row) => row.id!}//Set this if object doesn't have id as unique id (like serverKey etc)
|
||||||
|
slots={{
|
||||||
|
toolbar: () => (
|
||||||
|
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setupData.reloadServers()} // Refresh only active servers
|
||||||
|
sx={{ marginRight: 2 }}
|
||||||
|
>
|
||||||
|
{setupData.serversLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<GridToolbarColumnsButton />
|
||||||
|
<GridToolbarDensitySelector />
|
||||||
|
<GridToolbarExport />
|
||||||
|
<GridToolbarQuickFilter sx={{ ml: "auto" }} /> {/* Keeps the search filter in the toolbar */}
|
||||||
|
</GridToolbarContainer>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
toolbar: {
|
||||||
|
showQuickFilter: true,
|
||||||
|
quickFilterProps: { /*debounceMs: 500/*Optional: Adds debounce to search*/ },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: {
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Server Edit Modal */}
|
||||||
|
{selectedRow && (
|
||||||
|
<ServerEdit
|
||||||
|
open={open}
|
||||||
|
server={selectedRow}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onSave={handleUpdateRow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Servers;
|
||||||
@ -4,7 +4,7 @@ import { useSetupData, SetupData } from "@/context/SetupDataContext";
|
|||||||
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress } from '@mui/material';
|
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress } from '@mui/material';
|
||||||
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
|
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
|
||||||
//import utils from '@/ts/utils';
|
//import utils from '@/ts/utils';
|
||||||
import { Target } from '@/types/target';
|
import Target from '@/types/target';
|
||||||
import TargetEdit from "@/components/modals/TargetEdit";
|
import TargetEdit from "@/components/modals/TargetEdit";
|
||||||
|
|
||||||
|
|
||||||
@ -67,6 +67,10 @@ function Targets() {
|
|||||||
// if (hadError)
|
// if (hadError)
|
||||||
// alert(hadErrorMessage); //TODO: Make this look better. MUI Alert popup?
|
// alert(hadErrorMessage); //TODO: Make this look better. MUI Alert popup?
|
||||||
//}
|
//}
|
||||||
|
const handleNew = () => {
|
||||||
|
setSelectedRow(null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
const handleEdit = (row: GridRowModel<Target>) => {
|
const handleEdit = (row: GridRowModel<Target>) => {
|
||||||
setSelectedRow(row);
|
setSelectedRow(row);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
@ -114,7 +118,15 @@ function Targets() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setupData.reloadTargets()} // Refresh only active targets
|
onClick={() => handleNew()}
|
||||||
|
sx={{ marginRight: 2 }}
|
||||||
|
>
|
||||||
|
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setupData.reloadTargets()}
|
||||||
sx={{ marginRight: 2 }}
|
sx={{ marginRight: 2 }}
|
||||||
>
|
>
|
||||||
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
|
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
|
||||||
@ -145,7 +157,7 @@ function Targets() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Target Edit Modal */}
|
{/* Target Edit Modal */}
|
||||||
{selectedRow && (
|
{open && (
|
||||||
<TargetEdit
|
<TargetEdit
|
||||||
open={open}
|
open={open}
|
||||||
target={selectedRow}
|
target={selectedRow}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export type SetupData = {
|
|||||||
setTargets: (updatedTarget: Target) => void;
|
setTargets: (updatedTarget: Target) => void;
|
||||||
servers: Server[];
|
servers: Server[];
|
||||||
reloadServers: () => void;
|
reloadServers: () => void;
|
||||||
|
setServers: (updatedServer: Server) => void;
|
||||||
reloadSetupData: () => void;
|
reloadSetupData: () => void;
|
||||||
targetsLoading: boolean;
|
targetsLoading: boolean;
|
||||||
serversLoading: boolean;
|
serversLoading: boolean;
|
||||||
@ -46,10 +47,9 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
|
|||||||
const targetsData = await targetsResponse.json();
|
const targetsData = await targetsResponse.json();
|
||||||
setTargets(targetsData);
|
setTargets(targetsData);
|
||||||
setTargetsLoading(false);
|
setTargetsLoading(false);
|
||||||
//const serversResponse = await fetch("/api/setup/servers"); //TODO: call once setup
|
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=false");
|
||||||
//const serversData = await serversResponse.json();
|
const serversData = await serversResponse.json();
|
||||||
|
|
||||||
const serversData: Server[] = [];
|
|
||||||
setServers(serversData);
|
setServers(serversData);
|
||||||
setServersLoading(false);
|
setServersLoading(false);
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
@ -63,8 +63,20 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateTargetCache = (updatedTarget: Target) => {
|
const updateTargetCache = (updatedTarget: Target) => {
|
||||||
setTargets((prevTargets) =>
|
setTargets((prevTargets) => {
|
||||||
prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
|
const targetExists = prevTargets.some((target) => target.id === updatedTarget.id);
|
||||||
|
return targetExists
|
||||||
|
? prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
|
||||||
|
: [...prevTargets, updatedTarget]; // Push new target if not found
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateServerCache = (updatedServer: Server) => {
|
||||||
|
updatedServer.password = "";
|
||||||
|
setServers((prevServers) =>
|
||||||
|
prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
|
||||||
);
|
);
|
||||||
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
|
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
|
||||||
};
|
};
|
||||||
@ -73,7 +85,7 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
|
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, setServers: updateServerCache, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</SetupDataContext.Provider>
|
</SetupDataContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
26
Surge365.MassEmailReact.Web/src/context/TitleContext.tsx
Normal file
26
Surge365.MassEmailReact.Web/src/context/TitleContext.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface TitleContextType {
|
||||||
|
title: string;
|
||||||
|
setTitle: (title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TitleContext = createContext<TitleContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const TitleProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [title, setTitle] = useState<string>("Default Title");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TitleContext.Provider value={{ title, setTitle }}>
|
||||||
|
{children}
|
||||||
|
</TitleContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTitle = (): TitleContextType => {
|
||||||
|
const context = useContext(TitleContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTitle must be used within a TitleProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -1,5 +1,10 @@
|
|||||||
export interface Server {
|
export interface Server {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
serverName: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Server;
|
||||||
@ -9,3 +9,4 @@ export interface Target {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Target;
|
||||||
Loading…
x
Reference in New Issue
Block a user