From 6f00235702a346497533f466703a58224f524b45 Mon Sep 17 00:00:00 2001 From: David Headrick Date: Fri, 28 Feb 2025 09:33:38 -0600 Subject: [PATCH] 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. --- .../Controllers/ServersController.cs | 57 ++++++ .../Controllers/TargetsController.cs | 18 +- Surge365.MassEmailReact.API/Program.cs | 2 + .../DTOs/ServerUpdateDto.cs | 18 ++ .../Interfaces/IServerRepository.cs | 16 ++ .../Interfaces/IServerService.cs | 11 ++ .../Interfaces/ITargetRepository.cs | 1 + .../Interfaces/ITargetService.cs | 1 + .../Entities/Server.cs | 20 ++ .../Entities/Target.cs | 8 +- .../DapperMaps/DapperConfiguration.cs | 1 + .../DapperMaps/ServerMap.cs | 22 +++ .../Repositories/ServerRepository.cs | 100 ++++++++++ .../Repositories/TargetRepository.cs | 31 ++++ .../Repositories/UserRepository.cs | 2 +- .../Services/ServerService.cs | 53 ++++++ .../Services/TargetService.cs | 18 ++ Surge365.MassEmailReact.Web/package-lock.json | 81 +++++++- Surge365.MassEmailReact.Web/package.json | 5 +- .../src/components/layouts/Layout.tsx | 103 +++++++---- .../src/components/modals/ServerEdit.tsx | 107 +++++++++++ .../src/components/modals/TargetEdit.tsx | 173 +++++++++++++----- .../src/components/pages/App.tsx | 53 +++++- .../src/components/pages/AppMain.tsx | 5 +- .../src/components/pages/Servers.tsx | 170 +++++++++++++++++ .../src/components/pages/Targets.tsx | 18 +- .../src/context/SetupDataContext.tsx | 24 ++- .../src/context/TitleContext.tsx | 26 +++ .../src/types/server.ts | 5 + .../src/types/target.ts | 1 + 30 files changed, 1043 insertions(+), 107 deletions(-) create mode 100644 Surge365.MassEmailReact.API/Controllers/ServersController.cs create mode 100644 Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs create mode 100644 Surge365.MassEmailReact.Application/Interfaces/IServerRepository.cs create mode 100644 Surge365.MassEmailReact.Application/Interfaces/IServerService.cs create mode 100644 Surge365.MassEmailReact.Domain/Entities/Server.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/DapperMaps/ServerMap.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Repositories/ServerRepository.cs create mode 100644 Surge365.MassEmailReact.Infrastructure/Services/ServerService.cs create mode 100644 Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx create mode 100644 Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx create mode 100644 Surge365.MassEmailReact.Web/src/context/TitleContext.tsx diff --git a/Surge365.MassEmailReact.API/Controllers/ServersController.cs b/Surge365.MassEmailReact.API/Controllers/ServersController.cs new file mode 100644 index 0000000..b03440a --- /dev/null +++ b/Surge365.MassEmailReact.API/Controllers/ServersController.cs @@ -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 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 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 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); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.API/Controllers/TargetsController.cs b/Surge365.MassEmailReact.API/Controllers/TargetsController.cs index d5d2061..898e4bf 100644 --- a/Surge365.MassEmailReact.API/Controllers/TargetsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/TargetsController.cs @@ -32,15 +32,29 @@ namespace Surge365.MassEmailReact.Server.Controllers var target = await _targetService.GetByIdAsync(id); return target is not null ? Ok(target) : NotFound($"Target with key '{id}' not found."); } + [HttpPost()] + public async Task 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}")] public async Task UpdateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto) { 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); 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); if (!success) diff --git a/Surge365.MassEmailReact.API/Program.cs b/Surge365.MassEmailReact.API/Program.cs index 5f9df2c..b6298ac 100644 --- a/Surge365.MassEmailReact.API/Program.cs +++ b/Surge365.MassEmailReact.API/Program.cs @@ -16,6 +16,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); app.UseDefaultFiles(); diff --git a/Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs b/Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs new file mode 100644 index 0000000..5b59809 --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs @@ -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; } = ""; + } +} diff --git a/Surge365.MassEmailReact.Application/Interfaces/IServerRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/IServerRepository.cs new file mode 100644 index 0000000..c58f812 --- /dev/null +++ b/Surge365.MassEmailReact.Application/Interfaces/IServerRepository.cs @@ -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 GetByIdAsync(int id, bool returnPassword = false); + Task> GetAllAsync(bool activeOnly = true, bool returnPassword = false); + Task UpdateAsync(Server server); + } +} diff --git a/Surge365.MassEmailReact.Application/Interfaces/IServerService.cs b/Surge365.MassEmailReact.Application/Interfaces/IServerService.cs new file mode 100644 index 0000000..5ceaef2 --- /dev/null +++ b/Surge365.MassEmailReact.Application/Interfaces/IServerService.cs @@ -0,0 +1,11 @@ +using Surge365.MassEmailReact.Domain.Entities; + +namespace Surge365.MassEmailReact.Application.Interfaces +{ + public interface IServerService + { + Task GetByIdAsync(int id, bool returnPassword = false); + Task> GetAllAsync(bool activeOnly = true, bool returnPassword = false); + Task UpdateAsync(ServerUpdateDto serverDto); + } +} diff --git a/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs index 746e317..f0c09c2 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs @@ -11,6 +11,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces { Task GetByIdAsync(int id); Task> GetAllAsync(bool activeOnly = true); + Task CreateAsync(Target target); Task UpdateAsync(Target target); } } diff --git a/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs b/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs index 2ba7c62..2e9ee3b 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs @@ -6,6 +6,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces { Task GetByIdAsync(int id); Task> GetAllAsync(bool activeOnly = true); + Task CreateAsync(TargetUpdateDto targetDto); Task UpdateAsync(TargetUpdateDto targetDto); } } diff --git a/Surge365.MassEmailReact.Domain/Entities/Server.cs b/Surge365.MassEmailReact.Domain/Entities/Server.cs new file mode 100644 index 0000000..65e6a1d --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Entities/Server.cs @@ -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() { } + } +} diff --git a/Surge365.MassEmailReact.Domain/Entities/Target.cs b/Surge365.MassEmailReact.Domain/Entities/Target.cs index 7780d19..6db2fcf 100644 --- a/Surge365.MassEmailReact.Domain/Entities/Target.cs +++ b/Surge365.MassEmailReact.Domain/Entities/Target.cs @@ -10,10 +10,10 @@ namespace Surge365.MassEmailReact.Domain.Entities { public int? Id { get; private set; } public int ServerId { get; set; } - public string Name { get; set; } - public string DatabaseName { get; set; } - public string ViewName { get; set; } - public string FilterQuery { get; set; } + public string Name { get; set; } = ""; + public string DatabaseName { get; set; } = ""; + public string ViewName { get; set; } = ""; + public string FilterQuery { get; set; } = ""; public bool AllowWriteBack { get; set; } public bool IsActive { get; set; } diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs index 30efc4e..53d32a7 100644 --- a/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs @@ -14,6 +14,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps FluentMapper.Initialize(config => { config.AddMap(new TargetMap()); + config.AddMap(new ServerMap()); }); } } diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/ServerMap.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/ServerMap.cs new file mode 100644 index 0000000..90cd373 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/ServerMap.cs @@ -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 + { + 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"); + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/ServerRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/ServerRepository.cs new file mode 100644 index 0000000..9b64bae --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/ServerRepository.cs @@ -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 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("mem_get_server_by_id", new { server_key = serverKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault(); + if (server != null && !returnPassword) + { + server.Password = ""; + } + return server; + } + public async Task> GetAllAsync(bool activeOnly = true, bool returnPassword = false) + { + ArgumentNullException.ThrowIfNull(_config); + ArgumentNullException.ThrowIfNull(_connectionStringName); + + using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName)); + + List servers = (await conn.QueryAsync("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 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("@success"); + + return success; + } + + //public void Add(Server server) + //{ + // Servers.Add(server); + //} + + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs index 79bc2d3..d75e019 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs @@ -56,6 +56,37 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories //return conn.Query("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList(); } + public async Task 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("@success"); + + if(success) + return parameters.Get("@target_key"); + + return null; + } public async Task UpdateAsync(Target target) { ArgumentNullException.ThrowIfNull(target); diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs index 6da0c14..18bd8e7 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/UserRepository.cs @@ -101,7 +101,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories 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) - return null; + throw new Exception("No users returned"); return LoadFromDataRow(ds.Tables[0]); } diff --git a/Surge365.MassEmailReact.Infrastructure/Services/ServerService.cs b/Surge365.MassEmailReact.Infrastructure/Services/ServerService.cs new file mode 100644 index 0000000..7a0529a --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/Services/ServerService.cs @@ -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 GetByIdAsync(int id, bool returnPassword = false) + { + return await _serverRepository.GetByIdAsync(id, returnPassword); + } + public async Task> GetAllAsync(bool activeOnly = true, bool returnPassword = false) + { + return await _serverRepository.GetAllAsync(activeOnly, returnPassword); + } + public async Task 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); + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs b/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs index 4d8671e..9c375cd 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs @@ -33,6 +33,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Services { return await _targetRepository.GetAllAsync(activeOnly); } + public async Task 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 UpdateAsync(TargetUpdateDto targetDto) { ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto)); diff --git a/Surge365.MassEmailReact.Web/package-lock.json b/Surge365.MassEmailReact.Web/package-lock.json index 74a1df1..b918550 100644 --- a/Surge365.MassEmailReact.Web/package-lock.json +++ b/Surge365.MassEmailReact.Web/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.1.1", + "@hookform/resolvers": "^4.1.2", "@mui/icons-material": "^6.4.5", "@mui/material": "^6.4.5", "@mui/x-charts": "^7.27.1", @@ -23,8 +24,10 @@ "react": "^19.0.0", "react-bootstrap": "^2.10.9", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-icons": "^5.3.0", - "react-router-dom": "^7.0.1" + "react-router-dom": "^7.0.1", + "yup": "^1.6.1" }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -1063,6 +1066,18 @@ "integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==", "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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1990,6 +2005,12 @@ "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": { "version": "4.26.0", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.26.0.tgz", @@ -3959,6 +3980,12 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4042,6 +4069,22 @@ "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": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -4358,6 +4401,12 @@ "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": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4371,6 +4420,12 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", @@ -4409,6 +4464,18 @@ "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": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -4659,6 +4726,18 @@ "funding": { "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" + } } } } diff --git a/Surge365.MassEmailReact.Web/package.json b/Surge365.MassEmailReact.Web/package.json index 2e7dbbc..f218917 100644 --- a/Surge365.MassEmailReact.Web/package.json +++ b/Surge365.MassEmailReact.Web/package.json @@ -14,6 +14,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.1.1", + "@hookform/resolvers": "^4.1.2", "@mui/icons-material": "^6.4.5", "@mui/material": "^6.4.5", "@mui/x-charts": "^7.27.1", @@ -26,8 +27,10 @@ "react": "^19.0.0", "react-bootstrap": "^2.10.9", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-icons": "^5.3.0", - "react-router-dom": "^7.0.1" + "react-router-dom": "^7.0.1", + "yup": "^1.6.1" }, "devDependencies": { "@eslint/js": "^9.19.0", diff --git a/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx b/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx index 8a878ff..f2d08b1 100644 --- a/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx +++ b/Surge365.MassEmailReact.Web/src/components/layouts/Layout.tsx @@ -1,5 +1,7 @@ // 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 Box from '@mui/material/Box'; import Drawer from '@mui/material/Drawer'; @@ -16,14 +18,26 @@ import ListItemButton from '@mui/material/ListItemButton'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; 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 Select, { SelectChangeEvent } from '@mui/material/Select'; 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; @@ -56,26 +70,26 @@ interface 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 iconButtonRef = React.useRef(null) + const iconButtonRef = React.useRef(null); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils? + const { title } = useTitle(); const handleDrawerOpen = () => { setOpen(true); - sendResize(); }; const handleDrawerClose = () => { 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) => { setMode(event.target.value as 'light' | 'dark'); if (iconButtonRef.current) { @@ -97,12 +111,12 @@ const Layout = ({ children }: LayoutProps) => { position="fixed" sx={(theme) => ({ zIndex: theme.zIndex.drawer + 1, - transition: theme.transitions.create(['width', 'margin','padding'], { + transition: theme.transitions.create(['width', 'margin', 'padding'], { easing: theme.transitions.easing.easeInOut, duration: theme.transitions.duration.leavingScreen, }), ...(open && { - width: `calc(100% - ${drawerWidth}px)`, + width: isMobile ? "100%" : `calc(100% - ${drawerWidth}px)`, ml: `${drawerWidth}px`, transition: theme.transitions.create(['width', 'margin', 'padding'], { easing: theme.transitions.easing.easeInOut, @@ -112,18 +126,30 @@ const Layout = ({ children }: LayoutProps) => { })} > - - - + {isMobile && open ? + + + : + + + + } - Surge365 Dashboard + {title} { color: 'white', // White text '& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow '& .MuiOutlinedInput-notchedOutline': { - borderColor: 'white', // Gray in light, white in dark + borderColor: 'white', }, '&:hover .MuiOutlinedInput-notchedOutline': { - borderColor: 'white', // Darker gray on hover in light + borderColor: 'white', borderWidth: 2 }, '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: 'white', // Even darker gray when focused in light + borderColor: 'white', borderWidth: 2 }, }} @@ -166,18 +192,19 @@ const Layout = ({ children }: LayoutProps) => { {/* Sidebar */} + @@ -187,8 +214,16 @@ const Layout = ({ children }: LayoutProps) => { {[ { text: 'Home', icon: , path: '/home' }, - { text: 'Targets', icon: , path: '/targets' }, - { text: 'Templates', icon: , path: '/templates' }, + { text: 'Servers', icon: , path: '/servers' }, + { text: 'Targets', icon: , path: '/targets' }, + { text: 'Test Lists', icon: , path: '/testLists' }, + { text: 'Blocked Emails', icon: , path: '/blockedEmails' }, + { text: 'Unsubscribe Urls', icon: , path: '/unsubscribeUrls' }, + { text: 'Templates', icon: , path: '/templates' }, + { text: 'New Mailings', icon: , path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard? + { text: 'Scheduled Mailings', icon: , path: '/scheduledMailings' }, // + { text: 'Active Mailings', icon: , path: '/activeMailings' }, + { text: 'Completed Mailings', icon: , path: '/completedMailings' }, ].map((item) => ( diff --git a/Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx new file mode 100644 index 0000000..2f4088f --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx @@ -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 }); + //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 ( + + Edit Server - id={formData.id} + + handleChange("name", e.target.value)} + margin="dense" + /> + handleChange("serverName", e.target.value)} + margin="dense" + /> + handleChange("port", e.target.value)} + margin="dense" + /> + handleChange("username", e.target.value)} + margin="dense" + /> + handleChange("password", e.target.value)} + margin="dense" + /> + + + + + + + ); +}; + +export default ServerEdit; diff --git a/Surge365.MassEmailReact.Web/src/components/modals/TargetEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/TargetEdit.tsx index 1a0530a..4536ac4 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/TargetEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/TargetEdit.tsx @@ -1,44 +1,105 @@ -import { useState } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Dialog, DialogTitle, DialogContent, TextField, + Autocomplete, DialogActions, Button, Switch, FormControlLabel, } 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 = { open: boolean; - target: Target; + target: Target | null; onClose: () => void; onSave: (updatedTarget: Target) => void; }; +const schema = yup.object().shape({ + id: yup.number(), + 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; + + return !setupData.targets.some( + (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 TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => { - const [formData, setFormData] = useState({ ...target }); + const isNew = !target || target.id === 0; + const setupData: SetupData = useSetupData(); + + const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm({ + mode: "onBlur", + defaultValues: target || defaultTarget, + resolver: yupResolver(schema) as Resolver, + context: { setupData } +, + }); + //const [formData, setFormData] = useState(target ? { ...target } : { ...defaultTarget }); + //const [serverError, setServerError] = useState(false); // Track validation const [loading, setLoading] = useState(false); - const handleChange = (field: keyof Target, value: string | boolean) => { - setFormData((prev) => ({ ...prev, [field]: value })); - }; + useEffect(() => { //Reset form to unedited state on open or target change + if (open) { + reset(target || defaultTarget, { keepDefaultValues: true }); + } + }, [open, target, reset]); - const handleSave = async () => { + //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); try { - const response = await fetch(`/api/targets/${formData.id}`, { - method: "PUT", + const response = await fetch(apiUrl, { + method: method, headers: { "Content-Type": "application/json" }, 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(); - onSave(updatedTarget); // Update UI optimistically + onSave(updatedTarget); onClose(); } catch (error) { console.error("Update error:", error); @@ -49,74 +110,98 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => { return ( - Edit Target + {isNew ? "Add Target" : "Edit Target id=" + target.id} - handleChange("id", e.target.value)} - margin="dense" - /> - handleChange("serverId", e.target.value)} - margin="dense" + ( + 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) => ( + + )} + /> + )} /> handleChange("name", e.target.value)} margin="dense" + error={!!errors.name} + helperText={errors.name?.message} /> handleChange("databaseName", e.target.value)} margin="dense" + error={!!errors.databaseName} + helperText={errors.databaseName?.message} /> handleChange("viewName", e.target.value)} margin="dense" + error={!!errors.viewName} + helperText={errors.viewName?.message} /> handleChange("filterQuery", e.target.value)} margin="dense" + error={!!errors.filterQuery} + helperText={errors.filterQuery?.message} /> handleChange("allowWriteBack", e.target.checked)} + {...register("allowWriteBack")} /> } label="Allow Write Back" /> - handleChange("isActive", e.target.checked)} + ( + field.onChange(e.target.checked)} + /> + } + label="Active" /> - } - label="Active" + )} /> + - diff --git a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx index 053fc71..b1b0406 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx @@ -1,18 +1,35 @@ // 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 Layout from '@/components/layouts/Layout'; import LayoutLogin from '@/components/layouts/LayoutLogin'; import Home from '@/components/pages/Home'; import Login from '@/components/pages/Login'; +import Servers from '@/components/pages/Servers'; import Targets from '@/components/pages/Targets'; import Templates from '@/components/pages/Templates'; import { ColorModeContext } from '@/theme/theme'; import { SetupDataProvider } from '@/context/SetupDataContext'; +import { useTitle } from "@/context/TitleContext"; import { createTheme, ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; +interface PageWrapperProps { + title: string; + children: ReactNode; +} + +const PageWrapper: React.FC = ({ title, children }) => { + const { setTitle } = useTitle(); + + useEffect(() => { + setTitle(title); + }, [title, setTitle]); + + return <>{children}; +}; const App = () => { const [mode, setMode] = React.useState<'light' | 'dark'>('light'); @@ -38,25 +55,41 @@ const App = () => { - - + + + + + + } + /> + + + + + } /> - - + + + + + } /> - - + + + + + } /> - + + + ); diff --git a/Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx b/Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx new file mode 100644 index 0000000..2e75e31 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx @@ -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(null); + const gridContainerRef = useRef(null); + const [selectedRow, setSelectedRow] = useState(null); + const [open, setOpen] = useState(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[] = [ + { + field: "actions", + headerName: "Actions", + sortable: false, + renderCell: (params: GridRenderCellParams) => ( + + ), + }, + { 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: () => ( +
+ Password + + {isPasswordVisible ? : } + +
+ ), + renderCell: (params: GridRenderCellParams) => + isPasswordVisible ? params.value : "••••••••", + }, + ]; + + + const handleEdit = (row: GridRowModel) => { + 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 ( + + + {isMobile ? ( + + { (!isPasswordVisible ? setupData.servers : servers)?.map((row) => ( + + + {row.name} + Name: {row.name} + Server Name: {row.serverName} + Port: {row.port} + Username: {row.username} + Password: {row.password} + + + ))} + + ) : ( + row.id!}//Set this if object doesn't have id as unique id (like serverKey etc) + slots={{ + toolbar: () => ( + + + + + + {/* Keeps the search filter in the toolbar */} + + ), + }} + slotProps={{ + toolbar: { + showQuickFilter: true, + quickFilterProps: { /*debounceMs: 500/*Optional: Adds debounce to search*/ }, + }, + }} + initialState={{ + pagination: { + paginationModel: { + pageSize: 20, + }, + }, + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + )} + + + {/* Server Edit Modal */} + {selectedRow && ( + setOpen(false)} + onSave={handleUpdateRow} + /> + )} + + ); +} + +export default Servers; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/pages/Targets.tsx b/Surge365.MassEmailReact.Web/src/components/pages/Targets.tsx index a39fb08..6e47ec5 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/Targets.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/Targets.tsx @@ -4,7 +4,7 @@ import { useSetupData, SetupData } from "@/context/SetupDataContext"; 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 utils from '@/ts/utils'; -import { Target } from '@/types/target'; +import Target from '@/types/target'; import TargetEdit from "@/components/modals/TargetEdit"; @@ -67,6 +67,10 @@ function Targets() { // if (hadError) // alert(hadErrorMessage); //TODO: Make this look better. MUI Alert popup? //} + const handleNew = () => { + setSelectedRow(null); + setOpen(true); + }; const handleEdit = (row: GridRowModel) => { setSelectedRow(row); setOpen(true); @@ -114,7 +118,15 @@ function Targets() { +