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:
David Headrick 2025-02-28 09:33:38 -06:00
parent 4180e50c9c
commit 6f00235702
30 changed files with 1043 additions and 107 deletions

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

View File

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

View File

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

View 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; } = "";
}
}

View File

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

View File

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

View File

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

View File

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

View 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() { }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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) {
@ -97,12 +111,12 @@ const Layout = ({ children }: LayoutProps) => {
position="fixed" position="fixed"
sx={(theme) => ({ sx={(theme) => ({
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin','padding'], { transition: theme.transitions.create(['width', 'margin', 'padding'], {
easing: theme.transitions.easing.easeInOut, easing: theme.transitions.easing.easeInOut,
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}>

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

View File

@ -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 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 TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
const [formData, setFormData] = useState<Target>({ ...target }); 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); const [loading, setLoading] = useState(false);
const handleChange = (field: keyof Target, value: string | boolean) => { useEffect(() => { //Reset form to unedited state on open or target change
setFormData((prev) => ({ ...prev, [field]: value })); 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); 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>

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@ -9,3 +9,4 @@ export interface Target {
isActive: boolean; isActive: boolean;
} }
export default Target;