Refactor authentication and implement target management

- Updated `AuthenticationController` to return user data instead of token.
- Added `ITargetService` and `ITargetRepository` to `Program.cs`.
- Enhanced `appsettings.json` with connection timeout and security notes.
- Modified `IAuthService` to reflect new authentication response structure.
- Implemented connection retry mechanism in `DataAccess.cs`.
- Refactored UI components to use MUI styling in `Layout.tsx`, `App.tsx`, and others.
- Created new files for target management, including `TargetsController`, `TargetUpdateDto`, and related services.
- Added `TargetEdit` modal for editing target details and `LineChartSample` for data visualization.
- Updated package dependencies in `package-lock.json` and project file.
This commit is contained in:
David Headrick 2025-02-26 17:42:40 -06:00
parent b3f266f9a8
commit 4180e50c9c
48 changed files with 3116 additions and 586 deletions

View File

@ -22,10 +22,11 @@ namespace Surge365.MassEmailReact.Server.Controllers
var authResponse = await _authService.Authenticate(request.Username, request.Password);
if (!authResponse.authenticated)
return Unauthorized(new { message = authResponse.errorMessage });
else if(authResponse.token == null)
else if(authResponse.data == null)
return Unauthorized(new { message = "Invalid credentials" });
return Ok(new { success = true, authResponse.token.Value.accessToken });
//TODO: Store user in session
return Ok(new { success = true, authResponse.data.Value.accessToken, authResponse.data.Value.user }); //TODO: Send refresh token in http only cookie.
}
[HttpPost("refreshtoken")]
public IActionResult RefreshToken([FromBody] RefreshTokenRequest request)

View File

@ -0,0 +1,54 @@
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 TargetsController : ControllerBase
{
private readonly ITargetService _targetService;
public TargetsController(ITargetService targetService)
{
_targetService = targetService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly)
{
var targets = await _targetService.GetAllAsync(activeOnly == null || activeOnly.Value ? true : false);
return Ok(targets);
}
[HttpGet("{key}")]
public async Task<IActionResult> GetByKey(int id)
{
var target = await _targetService.GetByIdAsync(id);
return target is not null ? Ok(target) : NotFound($"Target with key '{id}' not found.");
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
{
if (id != targetUpdateDto.Id)
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");
var success = await _targetService.UpdateAsync(targetUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update target.");
var updatedTarget = await _targetService.GetByIdAsync(id);
return Ok(updatedTarget);
}
}
}

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Identity;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Infrastructure.DapperMaps;
using Surge365.MassEmailReact.Infrastructure.Repositories;
using Surge365.MassEmailReact.Infrastructure.Services;
@ -12,6 +14,8 @@ builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITargetService, TargetService>();
builder.Services.AddScoped<ITargetRepository, TargetRepository>();
var app = builder.Build();
app.UseDefaultFiles();
@ -31,4 +35,6 @@ app.MapControllers();
app.MapFallbackToFile("/index.html");
DapperConfiguration.ConfigureMappings();
app.Run();

View File

@ -12,6 +12,7 @@
"AppCode": "MassEmailReactApi",
"EnvironmentCode": "UAT",
"ConnectionStrings": {
"Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Application Name=##application_name##"
"Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
"MassEmail.ConnectionString": "data source=uat.surge365.com;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
}
}

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 TargetUpdateDto
{
public int? Id { get; 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 bool AllowWriteBack { get; set; } = false;
public bool IsActive { get; set; } = true;
}
}

View File

@ -1,8 +1,10 @@
namespace Surge365.MassEmailReact.Application.Interfaces
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IAuthService
{
Task<(bool authenticated, (string accessToken, string refreshToken)? token, string errorMessage)> Authenticate(string username, string password);
Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password);
(string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken);
}
}

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 ITargetRepository
{
Task<Target?> GetByIdAsync(int id);
Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<bool> UpdateAsync(Target target);
}
}

View File

@ -0,0 +1,11 @@
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface ITargetService
{
Task<Target?> GetByIdAsync(int id);
Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<bool> UpdateAsync(TargetUpdateDto targetDto);
}
}

View File

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class Target
{
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 bool AllowWriteBack { get; set; }
public bool IsActive { get; set; }
public Target() { }
private Target(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive)
{
Id = id;
ServerId = ServerId;
Name = name;
DatabaseName = databaseName;
ViewName = viewName;
FilterQuery = filterQuery;
AllowWriteBack = allowWriteBack;
IsActive = isActive;
}
public static Target Create(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive)
{
return new Target(id, serverId, name, databaseName, viewName, filterQuery, allowWriteBack, isActive);
}
}
}

View File

@ -21,7 +21,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
UserKey = userKey;
UserId = userId;
Username = username;
Username = firstName ?? "";
FirstName = firstName ?? "";
MiddleInitial = middleInitial ?? "";
LastName = lastName ?? "";
IsActive = isActive;

View File

@ -0,0 +1,20 @@
using Dapper.FluentMap;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class DapperConfiguration
{
public static void ConfigureMappings()
{
FluentMapper.Initialize(config =>
{
config.AddMap(new TargetMap());
});
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class TargetMap : EntityMap<Target>
{
public TargetMap()
{
Map(t => t.Id).ToColumn("target_key");
Map(t => t.ServerId).ToColumn("server_key");
Map(t => t.Name).ToColumn("name");
Map(t => t.DatabaseName).ToColumn("database_name");
Map(t => t.ViewName).ToColumn("view_name");
Map(t => t.FilterQuery).ToColumn("filter_query");
Map(t => t.AllowWriteBack).ToColumn("allow_write_back");
Map(t => t.IsActive).ToColumn("is_active");
}
}
}

View File

@ -233,8 +233,37 @@ namespace Surge365.MassEmailReact.Infrastructure
#region Async
internal async Task OpenConnectionAsync()
{
//_connection = new SqlConnection(_connectionString);
//await _connection.OpenAsync();
await Task.Run(() => OpenConnectionWithRetry());
}
internal void OpenConnectionWithRetry(short maxRetries = 3, int totalTimeoutMs = 1000)
{
int attempt = 0;
while (attempt < maxRetries)
{
using (var cts = new CancellationTokenSource(totalTimeoutMs)) // Total cap, e.g., 3 seconds
{
try
{
Console.WriteLine($"Attempt {attempt + 1}...");
_connection = new SqlConnection(_connectionString);
await _connection.OpenAsync();
_connection.OpenAsync(cts.Token).Wait(); // Use async with cancellation
return;
}
catch (Exception ex)
{
attempt++;
if (attempt == maxRetries)
{
Console.WriteLine($"Failed after {attempt} attempts: {ex.Message}");
throw;
}
Console.WriteLine($"Retrying after failure: {ex.Message}");
Thread.Sleep(1000); // Delay between retries
}
}
}
}
internal async Task CloseConnectionAsync()
{

View File

@ -0,0 +1,92 @@
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 TargetRepository : ITargetRepository
{
private IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
private string? ConnectionString
{
get
{
return _config.GetConnectionString(_connectionStringName);
}
}
public TargetRepository(IConfiguration config)
{
_config = config;
#if DEBUG
if (!FluentMapper.EntityMaps.ContainsKey(typeof(Target)))
{
throw new InvalidOperationException("Target dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
}
#endif
}
public async Task<Target?> GetByIdAsync(int targetKey)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<Target>("SELECT * FROM mem_target WHERE target_key = @target_key", new { target_key = targetKey })).ToList().FirstOrDefault();
}
public async Task<List<Target>> GetAllAsync(bool activeOnly = true)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<Target>("mem_get_target_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
//return conn.Query<Target>("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList();
}
public async Task<bool> UpdateAsync(Target target)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(target.Id);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@target_key", target.Id, DbType.Int32);
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");
return success;
}
//public void Add(Target target)
//{
// Targets.Add(target);
//}
}
}

View File

@ -26,7 +26,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
_config = config;
}
public async Task<(bool authenticated, (string accessToken, string refreshToken)? token, string errorMessage)> Authenticate(string username, string password)
public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password)
{
var authResponse = await _userRepository.Authenticate(username, password);
if (authResponse.user == null)
@ -56,7 +56,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
var refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
//_userRepository.SaveRefreshToken(userId.Value, refreshToken); // TODO: Store refresh token in DB
return (true, (accessToken, refreshToken), "");
return (true, (authResponse.user, accessToken, refreshToken), "");
}
public (string accessToken, string refreshToken)? GenerateTokens(Guid userId, string refreshToken)
{

View File

@ -0,0 +1,55 @@
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 TargetService : ITargetService
{
private readonly ITargetRepository _targetRepository;
private readonly IConfiguration _config;
public TargetService(ITargetRepository targetRepository, IConfiguration config)
{
_targetRepository = targetRepository;
_config = config;
}
public async Task<Target?> GetByIdAsync(int id)
{
return await _targetRepository.GetByIdAsync(id);
}
public async Task<List<Target>> GetAllAsync(bool activeOnly = true)
{
return await _targetRepository.GetAllAsync(activeOnly);
}
public async Task<bool> UpdateAsync(TargetUpdateDto targetDto)
{
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
ArgumentNullException.ThrowIfNull(targetDto.Id, nameof(targetDto.Id));
var target = await _targetRepository.GetByIdAsync(targetDto.Id.Value);
if (target == null || target.Id == null) return false;
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.UpdateAsync(target);
}
}
}

View File

@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper.FluentMap" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />

View File

@ -16,4 +16,7 @@
<None Remove="src\components\layouts\LayoutLogin_Backup.tsx" />
<None Remove="src\components\layouts\Layout_backup.tsx" />
</ItemGroup>
<ItemGroup>
<Folder Include="src\hooks\" />
</ItemGroup>
</Project>

View File

@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/components/pages/main.tsx"></script>
<script type="module" src="/src/components/pages/AppMain.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -11,13 +11,21 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1",
"@mui/x-data-grid": "^7.27.1",
"admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3",
"dayjs": "^1.11.13",
"font-awesome": "^4.7.0",
"ionicons": "^7.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-bootstrap": "^2.10.9",
"react-dom": "^19.0.0",
"react-icons": "^5.3.0",
"react-router-dom": "^7.0.1"
},

View File

@ -1,120 +1,212 @@
import { ReactNode } from 'react';
//import React from 'react';
import PropTypes from 'prop-types';
// src/components/layouts/Layout.tsx
import React, { ReactNode } from 'react';
import { styled, useColorScheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import List from '@mui/material/List';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ListItem from '@mui/material/ListItem';
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 { 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 'bootstrap/dist/css/bootstrap.min.css';
import 'admin-lte/dist/css/adminlte.min.css';
import 'font-awesome/css/font-awesome.min.css';
/*import 'ionicons/dist/css/ionicons.min.css';*/
// Constants
const drawerWidth = 240;
import 'bootstrap/dist/js/bootstrap.bundle.min.js'; // Bootstrap JS
import 'admin-lte/dist/js/adminlte.min.js';
// Styled components
const DrawerHeader = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0, 1),
...theme.mixins.toolbar,
justifyContent: 'flex-end',
}));
const Layout = function Layout({ children }: { children: ReactNode }) {
return (
<div className="wrapper" style={{ overflow: 'initial' }}>
<link rel="stylesheet" href="/content/dist/css/skins/_all-skins.min.css" />
<link href="/content/plugins/datepicker/v4/bootstrap-datetimepicker.css" rel="stylesheet" />
<link rel="stylesheet" href="/content/plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.min.css" />
<link rel="stylesheet" href="/content/plugins/datatables/dataTables.bootstrap.css" />
const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{
open?: boolean;
}>(({ theme, open }) => ({
flexGrow: 1,
padding: theme.spacing(3),
transition: theme.transitions.create(['margin', 'width', 'padding'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: "0px !important", // Force remove any margin on the left
marginRight: "0px !important", // Force remove any margin on the left
...(open && {/*Opened specific types go here*/}),
...(!open && {/*closed specific styles go here*/})
}));
{/*<script src="/content/dist/js/app.min.js"></script>AdminLTE?*/}
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.2/moment.min.js"></script>
{/*<script src="/content/plugins/datatables/jquery.dataTables.min.js"></script>*/}
{/*<script src="/content/plugins/validator/validator.js"></script>*/}
<script src="/content/js/jquery.surge365.utilities-1.0.js" />
<script src="/content/js/jquery.surge365.global.js?36"></script>
<script src="/content/js/jquery.surge365.webmethods-1.0.js?15"></script>
<script src="/content/js/Main-1.0.js?39"></script>
{/*<script src="/content/plugins/growl/jquery.bootstrap-growl.min.js"></script>*/}
{/*<script src="/content/plugins/input-mask-5/jquery.inputmask.js"></script>*/}
{/*<script src="/content/plugins/printThis/printThis.js"></script>*/}
{/*<script src="/content/plugins/datatables/dataTables.bootstrap.min.js"></script>*/}
<header className="main-header">
<a href="Dashboard" className="logo" style={{ backgroundColor: '#333' }}>
<span className="logo-mini" style={{ backgroundColor: '#333' }}>
<img id="imgLogo-sm" src="/content/img/imove_mini_logo.png" style={{ height: '35px', marginBottom: '3px' }} alt="Logo" className="hide" />
</span>
<span className="logo-lg" style={{ textAlign: 'center', backgroundColor: '#333' }}>
<img id="imgLogo-lg" src="/content/img/imove_mini_logo.png" style={{ height: '35px', marginBottom: '3px' }} alt="Logo" className="hide" />
<span style={{ marginLeft: '5px', verticalAlign: 'middle' }}><b>USA</b>Haulers</span>
</span>
</a>
<nav className="navbar navbar-static-top">
<a href="#" className="fa5 sidebar-toggle" data-toggle="offcanvas" role="button">
<span className="sr-only">Toggle navigation</span>
</a>
<div className="navbar-custom-menu">
<ul className="nav navbar-nav">
<li className="dropdown user user-menu">
<a href="#" className="dropdown-toggle" data-toggle="dropdown">
<span id="spanSpinner" className="fa fa-sync-alt pull-left" style={{ lineHeight: 'unset', display: 'none', fontSize: '16pt', fontWeight: 'bold', marginRight: '10px' }}></span>
<img id="imgRightProfile" src="/content/img/generic_avatar.jpg" className="user-image" alt="User Image" />
<span id="spanProfileName"></span>
</a>
<ul className="dropdown-menu">
<li className="user-header">
<img id="imgMainProfile" src="" className="img-circle" alt="User Image" />
<p>
<span id="spanProfileNameTitle"></span>
</p>
</li>
<li className="user-footer">
<div className="pull-left">
<a href="Profile" className="btn btn-default btn-flat">Profile</a>
</div>
<div className="pull-right">
<input type="button" id="btnSignOut" className="btn btn-primary" value="Sign Out" />
</div>
</li>
</ul>
</li>
</ul>
</div>
</nav>
</header>
<aside className="main-sidebar">
<section className="sidebar">
<div className="user-panel">
<div className="pull-left image">
<img id="imgLeftProfile" src="/content/img/generic_avatar.jpg" className="img-circle" alt="User Image" />
</div>
<div className="pull-left info" style={{ lineHeight: 3 }}>
<p><span id="spanMenuName"></span></p>
</div>
</div>
<ul className="sidebar-menu">
<li className="header">MAIN NAVIGATION</li>
<li id="liDashboard" className="active treeview">
<a href="Dashboard">
<i className="fa fa-home"></i><span>Dashboard</span>
</a>
</li>
<li className="treeview">
<a href="#" id="aSignOut">
<i className="fa fa-times-circle"></i>
<span>Logout</span>
</a>
</li>
</ul>
</section>
</aside>
{children}
<footer className="main-footer" style={{ padding: '2px' }}>
<div className="pull-right hidden-xs">
<b>Version</b> 1.0.0
</div>
<strong>Copyright &copy; 2024 <a href="https://www.surge365.com">Surge365</a>.</strong> All rights reserved.
</footer>
</div>
);
interface LayoutProps {
children: ReactNode;
}
Layout.propTypes = {
children: PropTypes.any
const Layout = ({ children }: LayoutProps) => {
const [open, setOpen] = React.useState(false);
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
const iconButtonRef = React.useRef<HTMLButtonElement>(null)
const handleDrawerOpen = () => {
setOpen(true);
sendResize();
};
const handleDrawerClose = () => {
setOpen(false);
sendResize();
};
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) {
const selectElement = iconButtonRef.current;
if (selectElement) {
if (selectElement instanceof HTMLElement) {
setTimeout(() => {
selectElement.focus(); // Blur the focusable input
}, 0);
}
}
}
};
return (
<Box sx={{ display: 'flex' }}>
{/* App Bar */}
<AppBar
position="fixed"
sx={(theme) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin','padding'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
width: `calc(100% - ${drawerWidth}px)`,
ml: `${drawerWidth}px`,
transition: theme.transitions.create(['width', 'margin', 'padding'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.enteringScreen,
}),
}),
})}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
ref={iconButtonRef}
sx={{ mr: 2, ...(open && { display: 'none' }) }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Surge365 Dashboard
</Typography>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel
id="theme-select-label"
sx={{
color: 'white', // White in both modes
'&.Mui-focused': { color: 'white' }, // Keep white when focused
}}
>
Theme
</InputLabel>
<Select
labelId="theme-select-label"
id="theme-select"
value={mode || 'light'}
label="Theme"
onChange={handleThemeChange}
sx={{
color: 'white', // White text
'& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'white', // Gray in light, white in dark
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'white', // Darker gray on hover in light
borderWidth: 2
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'white', // Even darker gray when focused in light
borderWidth: 2
},
}}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
</Toolbar>
</AppBar>
{/* Sidebar */}
<Drawer
variant="persistent"
anchor="left"
open={open}
sx={{
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
boxSizing: 'border-box',
},
}}
>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
<ChevronLeftIcon />
</IconButton>
</DrawerHeader>
<Divider />
<List>
{[
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
{ text: 'Targets', icon: <DirectionsCarIcon />, path: '/targets' },
{ text: 'Templates', icon: <PeopleIcon />, path: '/templates' },
].map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton component={RouterLink} to={item.path}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
{/* Main Content */}
<Main open={open}>
<DrawerHeader />
{children}
</Main>
</Box>
);
};
export default Layout;

View File

@ -0,0 +1,101 @@
import React from 'react';
import AdminLTELogo from 'admin-lte/dist/assets/img/AdminLTELogo.png';
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div className="app-wrapper">
{/* Header */}
<nav className="app-header navbar navbar-expand bg-body">
<div className="container-fluid">
{/* Start Navbar Links */}
<ul className="navbar-nav">
<li className="nav-item">
<a className="nav-link" data-lte-toggle="sidebar" href="#" role="button">
<i className="bi bi-list"></i>
</a>
</li>
<li className="nav-item d-none d-md-block">
<a href="./home" className="nav-link">Home</a>
</li>
<li className="nav-item d-none d-md-block">
<a href="#" className="nav-link">TODO</a>
</li>
</ul>
{/* End Navbar Links */}
<ul className="navbar-nav ms-auto">
<li className="nav-item">
<a className="nav-link" data-widget="navbar-search" href="#" role="button">
<i className="bi bi-search"></i>
</a>
</li>
{/* Additional navbar items (messages, notifications, user menu) can go here */}
</ul>
</div>
</nav>
{/* Sidebar */}
<aside className="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
<div className="sidebar-brand">
<a href="./home" className="brand-link">
<img
src={AdminLTELogo}
alt="AdminLTE Logo"
className="brand-image opacity-75 shadow"
/>
<span className="brand-text fw-light">AdminLTE 4</span>
</a>
</div>
<div className="sidebar-wrapper">
<nav className="mt-2">
<ul
className="nav sidebar-menu flex-column"
data-lte-toggle="treeview"
role="menu"
data-accordion="false"
>
<li className="nav-item menu-open">
<a href="./targets" className="nav-link active">
<i className="nav-icon bi bi-speedometer"></i>
<p>
Targets
<i className="nav-arrow bi bi-chevron-right"></i>
</p>
</a>
</li>
<li className="nav-item menu-open">
<a href="./templates" className="nav-link active">
<i className="nav-icon bi bi-speedometer"></i>
<p>
Templates
<i className="nav-arrow bi bi-chevron-right"></i>
</p>
</a>
</li>
{/* Additional sidebar menu items can be added here */}
</ul>
</nav>
</div>
</aside>
{/* Main Content Area */}
<main className="app-main">
{children}
</main>
{/* Footer */}
<footer className="app-footer">
<div className="float-end d-none d-sm-inline">Version 0.0.1</div>
<strong>
Copyright &copy; 2025&nbsp;
<a href="https://adminlte.io" className="text-decoration-none">Surge365</a>.
</strong>
All rights reserved.
</footer>
</div>
);
};
export default Layout;

View File

@ -0,0 +1,120 @@
import { ReactNode } from 'react';
//import React from 'react';
import PropTypes from 'prop-types';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'admin-lte/dist/css/adminlte.min.css';
import 'font-awesome/css/font-awesome.min.css';
/*import 'ionicons/dist/css/ionicons.min.css';*/
import 'bootstrap/dist/js/bootstrap.bundle.min.js'; // Bootstrap JS
import 'admin-lte/dist/js/adminlte.min.js';
const Layout = function Layout({ children }: { children: ReactNode }) {
return (
<div className="wrapper" style={{ overflow: 'initial' }}>
<link rel="stylesheet" href="/content/dist/css/skins/_all-skins.min.css" />
<link href="/content/plugins/datepicker/v4/bootstrap-datetimepicker.css" rel="stylesheet" />
<link rel="stylesheet" href="/content/plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.min.css" />
<link rel="stylesheet" href="/content/plugins/datatables/dataTables.bootstrap.css" />
{/*<script src="/content/dist/js/app.min.js"></script>AdminLTE?*/}
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.2/moment.min.js"></script>
{/*<script src="/content/plugins/datatables/jquery.dataTables.min.js"></script>*/}
{/*<script src="/content/plugins/validator/validator.js"></script>*/}
<script src="/content/js/jquery.surge365.utilities-1.0.js" />
<script src="/content/js/jquery.surge365.global.js?36"></script>
<script src="/content/js/jquery.surge365.webmethods-1.0.js?15"></script>
<script src="/content/js/Main-1.0.js?39"></script>
{/*<script src="/content/plugins/growl/jquery.bootstrap-growl.min.js"></script>*/}
{/*<script src="/content/plugins/input-mask-5/jquery.inputmask.js"></script>*/}
{/*<script src="/content/plugins/printThis/printThis.js"></script>*/}
{/*<script src="/content/plugins/datatables/dataTables.bootstrap.min.js"></script>*/}
<header className="main-header">
<a href="Dashboard" className="logo" style={{ backgroundColor: '#333' }}>
<span className="logo-mini" style={{ backgroundColor: '#333' }}>
<img id="imgLogo-sm" src="/content/img/imove_mini_logo.png" style={{ height: '35px', marginBottom: '3px' }} alt="Logo" className="hide" />
</span>
<span className="logo-lg" style={{ textAlign: 'center', backgroundColor: '#333' }}>
<img id="imgLogo-lg" src="/content/img/imove_mini_logo.png" style={{ height: '35px', marginBottom: '3px' }} alt="Logo" className="hide" />
<span style={{ marginLeft: '5px', verticalAlign: 'middle' }}><b>USA</b>Haulers</span>
</span>
</a>
<nav className="navbar navbar-static-top">
<a href="#" className="fa5 sidebar-toggle" data-toggle="offcanvas" role="button">
<span className="sr-only">Toggle navigation</span>
</a>
<div className="navbar-custom-menu">
<ul className="nav navbar-nav">
<li className="dropdown user user-menu">
<a href="#" className="dropdown-toggle" data-toggle="dropdown">
<span id="spanSpinner" className="fa fa-sync-alt pull-left" style={{ lineHeight: 'unset', display: 'none', fontSize: '16pt', fontWeight: 'bold', marginRight: '10px' }}></span>
<img id="imgRightProfile" src="/content/img/generic_avatar.jpg" className="user-image" alt="User Image" />
<span id="spanProfileName"></span>
</a>
<ul className="dropdown-menu">
<li className="user-header">
<img id="imgMainProfile" src="" className="img-circle" alt="User Image" />
<p>
<span id="spanProfileNameTitle"></span>
</p>
</li>
<li className="user-footer">
<div className="pull-left">
<a href="Profile" className="btn btn-default btn-flat">Profile</a>
</div>
<div className="pull-right">
<input type="button" id="btnSignOut" className="btn btn-primary" value="Sign Out" />
</div>
</li>
</ul>
</li>
</ul>
</div>
</nav>
</header>
<aside className="main-sidebar">
<section className="sidebar">
<div className="user-panel">
<div className="pull-left image">
<img id="imgLeftProfile" src="/content/img/generic_avatar.jpg" className="img-circle" alt="User Image" />
</div>
<div className="pull-left info" style={{ lineHeight: 3 }}>
<p><span id="spanMenuName"></span></p>
</div>
</div>
<ul className="sidebar-menu">
<li className="header">MAIN NAVIGATION</li>
<li id="liDashboard" className="active treeview">
<a href="Dashboard">
<i className="fa fa-home"></i><span>Dashboard</span>
</a>
</li>
<li className="treeview">
<a href="#" id="aSignOut">
<i className="fa fa-times-circle"></i>
<span>Logout</span>
</a>
</li>
</ul>
</section>
</aside>
{children}
<footer className="main-footer" style={{ padding: '2px' }}>
<div className="pull-right hidden-xs">
<b>Version</b> 1.0.0
</div>
<strong>Copyright &copy; 2024 <a href="https://www.surge365.com">Surge365</a>.</strong> All rights reserved.
</footer>
</div>
);
}
Layout.propTypes = {
children: PropTypes.any
};
export default Layout;

View File

@ -0,0 +1,127 @@
import { useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
Switch,
FormControlLabel,
} from "@mui/material";
import { Target } from "@/types/target";
type TargetEditProps = {
open: boolean;
target: Target;
onClose: () => void;
onSave: (updatedTarget: Target) => void;
};
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
const [formData, setFormData] = useState<Target>({ ...target });
const [loading, setLoading] = useState(false);
const handleChange = (field: keyof Target, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setLoading(true);
try {
const response = await fetch(`/api/targets/${formData.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error("Failed to update");
const updatedTarget = await response.json();
onSave(updatedTarget); // 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 Target</DialogTitle>
<DialogContent>
<TextField
label="Target Key"
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"
/>
<TextField
label="Name"
fullWidth
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
margin="dense"
/>
<TextField
label="Database Name"
fullWidth
value={formData.databaseName}
onChange={(e) => handleChange("databaseName", e.target.value)}
margin="dense"
/>
<TextField
label="View Name"
fullWidth
value={formData.viewName}
onChange={(e) => handleChange("viewName", e.target.value)}
margin="dense"
/>
<TextField
label="Filter Query"
fullWidth
value={formData.filterQuery}
onChange={(e) => handleChange("filterQuery", e.target.value)}
margin="dense"
/>
<FormControlLabel
control={
<Switch
checked={formData.allowWriteBack}
onChange={(e) => handleChange("allowWriteBack", e.target.checked)}
/>
}
label="Allow Write Back"
/>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={(e) => handleChange("isActive", e.target.checked)}
/>
}
label="Active"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSave} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default TargetEdit;

View File

@ -1,21 +1,61 @@
// App.tsx or main routing component
//import React from 'react';
import React 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 Vehicles from '@/components/pages/Vehicles';
import Home from '@/components/pages/Home';
import Login from '@/components/pages/Login';
import Targets from '@/components/pages/Targets';
import Templates from '@/components/pages/Templates';
import { ColorModeContext } from '@/theme/theme';
import { SetupDataProvider } from '@/context/SetupDataContext';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
const App = () => {
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
const colorMode = React.useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'));
},
}),
[]
);
const theme = React.useMemo(() => createTheme({ palette: { mode } }), [mode]);
return (
<ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}>
<CssBaseline />
<SetupDataProvider>
<Router basename="/">
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route
path="/vehicles"
path="/home"
element={
<Layout>
<Vehicles />
<Home />
</Layout>
}
/>
<Route
path="/targets"
element={
<Layout>
<Targets />
</Layout>
}
/>
<Route
path="/templates"
element={
<Layout>
<Templates />
</Layout>
}
/>
@ -29,6 +69,9 @@ const App = () => {
/>
</Routes>
</Router>
</SetupDataProvider>
</ThemeProvider>
</ColorModeContext.Provider>
);
};

View File

@ -0,0 +1,79 @@
import React from 'react';
import { createRoot } from 'react-dom/client'
import { createTheme, ThemeProvider } from '@mui/material/styles';
import '@/css/main.css'
import App from '@/components/pages/App'
import '@/config/constants';
//DEFAULT THEMES
const theme = createTheme({
cssVariables: {
colorSchemeSelector: 'class'
},
colorSchemes: {
light: true, // Default light scheme
dark: true, // Default dark scheme
},
components: {
MuiAppBar: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: theme.vars.palette.primary.main,
color: theme.vars.palette.text.primary,
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
notchedOutline: ({ theme }) => ({
borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[500] : theme.vars.palette.text.primary,
'&:hover': {
borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[700] : theme.vars.palette.text.primary,
},
}),
root: ({ theme }) => ({
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.mode === 'light' ? theme.vars.palette.grey[900] : theme.vars.palette.text.primary,
borderWidth: 2, // Match MUI default focus width
},
}),
},
},
},
});
//CUSTOM THEMES
//const theme = createTheme({
// cssVariables: {
// colorSchemeSelector: 'class'
// },
// colorSchemes: {
// light: {
// palette: {
// primary: { main: '#1976d2' },
// background: { default: '#fff', paper: '#f5f5f5' },
// },
// },
// dark: {
// palette: {
// primary: { main: '#90caf9' },
// background: { default: '#121212', paper: '#424242' },
// },
// },
// },
//});
const rootElement = document.getElementById('root');
if (rootElement) {
createRoot(rootElement).render(
<React.StrictMode>
<ThemeProvider theme={theme} defaultMode="system">
<App />
</ThemeProvider>
</React.StrictMode>
);
} else {
throw new Error('Root element not found');
}

View File

@ -0,0 +1,36 @@
// src/components/pages/Home.tsx
//import React from 'react';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Grid2 from '@mui/material/Grid2'; // v6 Grid2
//import Card from '@mui/material/Card';
//import CardContent from '@mui/material/CardContent';
//import { CardActionArea } from '@mui/material';
import { BarChart } from '@mui/x-charts/BarChart';
import LineChartSample from '@/components/widgets/LineChartSample';
const Home = () => {
return (
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" component="h1" gutterBottom>
Welcome to Surge365
</Typography>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6, md: 6 }}>
<BarChart
xAxis={[{ scaleType: 'band', data: ['group A', 'group B', 'group C'] }]}
series={[{ data: [4, 3, 5] }, { data: [1, 6, 3] }, { data: [2, 5, 6] }]}
width={500}
height={300}
/>
</Grid2>
<Grid2 size={{ xs: 12, sm: 6, md: 4 }}>
<LineChartSample />
</Grid2>
</Grid2>
</Box>
);
};
export default Home;

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { Button, Form, Spinner } from 'react-bootstrap';
import { AuthResponse, AuthErrorResponse, User, isAuthErrorResponse } from '@/types/auth';
//import { Helmet, HelmetProvider } from 'react-helmet-async';
import utils from '@/ts/utils.ts';
@ -15,8 +16,9 @@ function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showForgotPasswordModal, setShowForgotPasswordModal] = useState(false);
const [user, setUser] = useState<any>(null);
//const [user, setUser] = useState<User | null>(null);
const [loginError, setLoginError] = useState<boolean>(false);
const [loginErrorMessage, setLoginErrorMessage] = useState<string>('');
//const setSpinners = (newValues: Partial<SpinnerState>) => {
// setSpinnersState((prevSpinners) => ({
@ -68,26 +70,50 @@ function Login() {
if (Object.keys(formErrors).length > 0) return;
//setUser(null);
setLoginError(false);
let loggedInUser: any = null;
await utils.webMethod({
setLoginErrorMessage('');
let loggedInUser: User | null = null;
let hadLoginError: boolean = false;
let hadLoginErrorMessage: string = '';
await utils.webMethod<AuthResponse>({
methodPage: 'authentication',
methodName: 'authenticate',
parameters: { username: username, password: password },
success: (json: any) => {
if (utils.getBoolean(json.success)) {
loggedInUser = json.data;
setUser(loggedInUser);
} else {
setLoginError(true);
setIsLoading(false);
spinners.Login = false;
parameters: { username, password },
success: (json: AuthResponse) => {
try {
loggedInUser = json.user;
//setUser(loggedInUser);
}
catch {
const errorMsg: string = "Unexpected Error";
hadLoginError = true;
hadLoginErrorMessage = errorMsg;
}
},
error: (err: unknown) => {
let errorMsg: string = "Unexpected Error";
if (isAuthErrorResponse(err)) {
if (err && err as AuthErrorResponse) {
if (err.data) {
if (err.data.message)
errorMsg = err.data.message;
}
console.error(errorMsg);
setLoginErrorMessage(errorMsg);
}
}
hadLoginError = true;
hadLoginErrorMessage = errorMsg;
}
});
if (loginError) {
if (hadLoginError) {
setLoginErrorMessage(hadLoginErrorMessage);
setLoginError(true);
setIsLoading(false);
spinners.Login = false;
setSpinners(spinners);
return;
}
@ -101,20 +127,20 @@ function Login() {
}
};
const finishUserLogin = async (user: any) => {
const finishUserLogin = async (loggedInUser: User) => {
setIsLoading(false);
spinners.Login = false;
spinners.LoginWithPasskey = false;
setSpinners(spinners);
utils.localStorage("session_currentUser", user);
utils.localStorage("session_currentUser", loggedInUser);
const redirectUrl = utils.sessionStorage("redirect_url");
if (redirectUrl) {
utils.sessionStorage("redirect_url", null);
document.location.href = redirectUrl;
} else {
document.location.href = '/vehicles';
document.location.href = '/home';
}
};
@ -127,7 +153,7 @@ function Login() {
<h3 className="form-signin-heading mt-3 mb-1">Please sign in</h3>
<Form id="frmLogin" onSubmit={handleLogin}>
{loginError && (
<Form.Label style={{ color: 'red' }}>Login error</Form.Label>
<Form.Label style={{ color: 'red' }}>{loginErrorMessage ?? "Login error"}</Form.Label>
)}
<Form.Group className="mb-3" controlId="txtUsernamel">
<Form.Label className="visually-hidden">Username</Form.Label>

View File

@ -0,0 +1,160 @@
import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
//import Typography from '@mui/material/Typography';
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 TargetEdit from "@/components/modals/TargetEdit";
function Targets() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
//const [targets, setTargets] = useState<Target[]>([]);
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<Target | null>(null);
const [open, setOpen] = useState<boolean>(false);
//TODO: Update columns to target format
const columns: GridColDef<Target>[] = [
{
field: "actions",
headerName: "Actions",
sortable: false,
renderCell: (params: GridRenderCellParams<Target>) => (
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
Edit
</Button>
),
},
{ field: "id", headerName: "ID", width: 60 },
//{ field: "serverKey", headerName: "Server Key", flex: 1, minWidth: 140 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "databaseName", headerName: "Database", flex: 1, minWidth: 100 },
{ field: "viewName", headerName: "View", flex: 1, minWidth: 300 },
{ field: "filterQuery", headerName: "Filter Query", flex: 1, minWidth: 100 },
{ field: "allowWriteBack", headerName: "WriteBack?", width: 100 },
{ field: "isActive", headerName: "Active", width: 75 },
];
//async function loadTargets(activeOnly: boolean) {
// let hadError = false;
// let hadErrorMessage = "";
// await utils.webMethod<[Target]>({
// httpMethod: 'GET',
// methodPage: 'targets',
// methodName: 'GetAll?activeOnly=' + activeOnly,
// success: (targets: [Target]) => {
// try {
// setTargets(targets);
// }
// catch {
// const errorMsg: string = "Unexpected Error";
// hadError = true;
// hadErrorMessage = errorMsg;
// }
// },
// error: () => {
// const errorMsg: string = "Unexpected Error";
// hadError = true;
// hadErrorMessage = errorMsg;
// }
// });
// if (hadError)
// alert(hadErrorMessage); //TODO: Make this look better. MUI Alert popup?
//}
const handleEdit = (row: GridRowModel<Target>) => {
setSelectedRow(row);
setOpen(true);
};
const handleUpdateRow = (updatedRow: Target) => {
setupData.setTargets(updatedRow);
};
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>
{setupData.targets.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}>
<CardContent>
<Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">Target Key: {row.id}</Typography>
{/*<Typography variant="body2">Server Key: {row.serverKey}</Typography>*/}
<Typography variant="body2">Database: {row.databaseName}</Typography>
<Typography variant="body2">View: {row.viewName}</Typography>
<Typography variant="body2">Filter: {row.filterQuery}</Typography>
<Typography variant="body2">Writeback: {row.allowWriteBack ? "Yes" : "No"}</Typography>
<Typography variant="body2">Active: {row.isActive ? "Yes" : "No"}</Typography>
</CardContent>
</Card>
))}
</List>
) : (
<DataGrid
rows={setupData.targets}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
//getRowId={(row) => row.id!}//Set this if object doesn't have id as unique id (like targetKey etc)
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadTargets()} // Refresh only active targets
sx={{ marginRight: 2 }}
>
{setupData.targetsLoading ? <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>
{/* Target Edit Modal */}
{selectedRow && (
<TargetEdit
open={open}
target={selectedRow}
onClose={() => setOpen(false)}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default Targets;

View File

@ -0,0 +1,63 @@
import React from 'react';
const Templates: React.FC = () => {
return (
<div>
{/* Content Header */}
<div className="app-content-header">
<div className="container-fluid">
<div className="row">
<div className="col-sm-6">
<h3 className="mb-0">Templates</h3>
</div>
<div className="col-sm-6">
<ol className="breadcrumb float-sm-end">
<li className="breadcrumb-item">
<a href="./home">Home</a>
</li>
<li className="breadcrumb-item active" aria-current="page">
Templates
</li>
</ol>
</div>
</div>
</div>
</div>
{/* Page-specific Content */}
<div className="app-content">
<div className="container-fluid">
<div className="row">
{/* Example: Small Box Widget 1 */}
<div className="col-lg-3 col-6">
<div className="small-box text-bg-primary">
<div className="inner">
<h3>150</h3>
<p>Templates</p>
</div>
<svg
className="small-box-icon"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.25 2.25a.75.75 0 000 1.5h1.386c.17 0 .318.114.362.278l2.558 9.592a3.752 3.752 0 00-2.806 3.63c0 .414.336.75.75.75h15.75a.75.75 0 000-1.5H5.378A2.25 2.25 0 017.5 15h11.218a.75.75 0 00.674-.421 60.358 60.358 0 002.96-7.228.75.75 0 00-.525-.965A60.864 60.864 0 005.68 4.509l-.232-.867A1.875 1.875 0 003.636 2.25H2.25zM3.75 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM16.5 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z"></path>
</svg>
<a
href="#"
className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover"
>
More info <i className="bi bi-link-45deg"></i>
</a>
</div>
</div>
{/* Additional small boxes, charts, or other widgets can be included here */}
</div>
{/* You can also add more rows for charts, direct chat, etc. */}
</div>
</div>
</div>
);
};
export default Templates;

View File

@ -1,370 +0,0 @@
//import { useState } from 'react';
function Vehicles() {
return (<div>
<link href="/content/plugins/filer/jquery.fileuploader.css" rel="stylesheet" />
<link href="/content/plugins/filer/drag-drop/css/jquery.fileuploader-theme-dragdrop.css" rel="stylesheet" />
<script src="/content/plugins/filer/jquery.fileuploader.js"></script>
<script src="/content/js/Vehicles-1.0.js"></script>
<div className="content-wrapper">
{/* Content Header (Page header) */}
<section className="content-header">
<h1>Vehicles
<small></small>
<button type="button" id="btnNewVehicle" className="btn btn-sm btn-success">New Vehicle</button>
<span id="spanLoadPatientsSpinner" className="fa fa-sync-alt" style={{fontSize: '20px', verticalAlign: 'middle', display: 'none' }}></span>
</h1>
</section>
<section className="content hide">
<div className="row">
<div className="col-md-6">
<div className="box">
<div className="box-body">
<form id="frmAddAdjustment" name="frmAddAdjustment" role="form" data-toggle="validator" className="form-horizontal">
<div className="row" role="main">
<div className="col-md-12">
<div className="form-group has-feedback">
<label className="col-sm-2 control-label">LMT</label>
<div className="col-sm-10">
<select id="selUser" className="form-control" required>
</select>
<span className="glyphicon form-control-feedback" aria-hidden="true" style={{ right: '25px' }}></span>
</div>
</div>
<div className="form-group has-feedback">
<label className="col-sm-2 control-label">Location</label>
<div className="col-sm-10">
<select id="selLocations" className="form-control" required>
</select>
<span className="glyphicon form-control-feedback" aria-hidden="true" style={{ right: '25px' }}></span>
</div>
</div>
<div className="form-group has-feedback has-success">
<label className="col-sm-2 control-label">Batch</label>
<div className="col-sm-10">
<select id="selBatch" className="form-control" required>
</select>
<span className="glyphicon form-control-feedback" aria-hidden="true" style={{ right: '25px' }}></span>
</div>
</div>
<div className="form-group has-feedback">
<label className="col-sm-2 control-label">Amount</label>
<div className="col-sm-4 has-feedback">
<input type="text" id="txtAmount" className="form-control" required pattern="^-?\d+(\.\d{1,2})?$" />
<span className="glyphicon form-control-feedback" aria-hidden="true" style={{ right: '25px' }}></span>
</div>
</div>
<div className="form-group">
<label className="col-sm-2 control-label">Revenue?</label>
<div className="col-sm-4 has-feedback">
<select id="selRevenue" className="form-control">
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
<span className="glyphicon form-control-feedback" aria-hidden="true" style={{ right: '25px' }}></span>
</div>
</div>
<div className="form-group has-feedback">
<label className="col-sm-2 control-label">Description</label>
<div className="col-sm-10">
<input type="text" id="txtDescription" className="form-control" required />
<span className="glyphicon form-control-feedback" aria-hidden="true" style={{ right: '25px' }}></span>
</div>
</div>
</div>
</div>
<button id="btnSaveAdjustment" type="button" className="btn btn-success pull-right ">Save Adjustment</button>
</form>
</div>
</div>
</div>
</div>
</section>
<section className="content">
{/* Info boxes */}
<div className="row">
<div className="col-md-12">
{/* Box Comment */}
<div className="box">
{/* /.box-header */}
<div className="box-body table-responsive table-condensed no-padding">
<table id="tblVehicles" className="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>VIN</th>
<th>Name</th>
<th>Price</th>
<th>Stock #</th>
<th>Year</th>
<th>Make</th>
<th>Model</th>
<th>Ext. Color</th>
<th>Int. Color</th>
<th>Seating</th>
<th>Transmission</th>
<th>Title</th>
<th>Featured<br />
Image</th>
<th>Images</th>
<th>View</th>
<th>Edit</th>
</tr>
</thead>
<tbody id="divRows">
</tbody>
</table>
</div>
{/* /.box-body */}
</div>
{/* /.box */}
</div>
{/* /.col */}
</div>
{/* /.row */}
</section>
</div>
<div id="divEditVehicle" className="modal fade">
<div className="modal-dialog modal-lg">
<div className="modal-content">
<form id="frmEditVehicle" name="frmEditVehicle" role="form" data-toggle="validator" data-focus="false" className="form-group-sm">
<div className="modal-header" style={{ background: '#00a65a' }} >
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title" style={{ color: 'white' }}><span id="spanEditType">Edit Patient</span></h4>
</div>
<div className="modal-body">
<div className="row" role="main">
<div className="col-md-12">
<div className="form-group has-feedback">
<label className="control-label">Display Name</label>
<div className="has-feedback">
<input type="text" id="txtDisplayName" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">VIN</label>
<div className="has-feedback">
<input type="text" id="txtVIN" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Stock #</label>
<div className="has-feedback">
<input type="text" id="txtStockNumber" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Year</label>
<div className="has-feedback">
<input type="text" id="txtYear" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Make</label>
<div className="has-feedback">
<input type="text" id="txtMake" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Model</label>
<div className="has-feedback">
<input type="text" id="txtModel" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Price</label>
<div className="has-feedback">
<input type="text" id="txtPrice" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Seating</label>
<div className="has-feedback">
<input type="text" id="txtSeating" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Transmission</label>
<div className="has-feedback">
<input type="text" id="txtTranmission" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Interior Color</label>
<div className="has-feedback">
<input type="text" id="txtInteriorColor" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Exterior Color</label>
<div className="has-feedback">
<input type="text" id="txtExteriorColor" className="form-control input-sm" required placeholder="" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-3">
<div className="form-group has-feedback">
<label className="control-label">Featured Image</label>
<div className="has-feedback">
<img style={{ width: '75px', height: 'auto' }} src="" type="text" id="imgFeaturedImage" />
<span className="glyphicon form-control-feedback" aria-hidden="true"></span>
</div>
</div>
</div>
<div className="col-md-12">
<div className="form-group has-feedback">
<label className="control-label">Additional Images</label>
<div className="drop-zone" id="drop-zone">
Drag and drop images here or click to select
</div>
<input type="file" id="file-input" multiple style={{ display: 'none' }} />
<div className="preview" id="preview"></div>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<div>
<button type="button" className="btn btn-default" data-dismiss="modal">Cancel</button>
<input id="btnSaveVehicle" type="button" className="btn btn-primary" value="Save" />
<span id="spanSpinner" className="fa fa-sync-alt" style={{ fontSize: '20px', verticalAlign: 'middle', display: 'none' }}></span>
</div>
</div>
</form>
</div>
{/* /.modal-content */}
</div>
{/* /.modal-dialog */}
</div>
<div id="divUploadDocument" className="modal fade">
<div className="modal-dialog">
<div className="modal-content">
<form id="frmUploadUserDocument" name="frmUploadUserDocument" role="form" data-toggle="validator" style={{ paddingTop: '5px' }} >
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span></button>
<h4 className="modal-title">Upload new document?</h4>
</div>
<div className="modal-body">
<div className="row" role="main">
<div className="col-md-12">
<div id="divNewDocument" className="col-sm-12">
<input type="file" name="newDocument" id="newDocument" />
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default pull-left" data-dismiss="modal">Cancel</button>
<input id="btnSubmitNewDocumentFinal" type="button" className="btn btn-danger" value="Submit Document" />
<span id="spanSubmitNewDocumentSpinner" className="fa fa-sync-alt" style={{ fontSize: '20px', verticalAlign: 'middle', display: 'none' }} ></span>
</div>
</form>
</div>
{/* /.modal-content */}
</div>
{/* /.modal-dialog */}
</div></div>
);
//return content;
}
export default Vehicles;

View File

@ -1,11 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/css/main.css'
import App from '@/components/pages/App.tsx'
import '@/config/constants';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/css/main.css'
import App from '@/components/pages/App'
import '@/config/constants';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'admin-lte/dist/css/adminlte.min.css';
import 'font-awesome/css/font-awesome.min.css';
/*import 'ionicons/dist/css/ionicons.min.css';*/
import 'bootstrap/dist/js/bootstrap.bundle.min.js'; // Bootstrap JS
import 'admin-lte/dist/js/adminlte.min.js';
const rootElement = document.getElementById('root');
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);
} else {
throw new Error('Root element not found');
}

View File

@ -0,0 +1,53 @@
// App.tsx or main routing component
//import React 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 Targets from '@/components/pages/Targets';
import Templates from '@/components/pages/Templates';
const App = () => {
return (
<Router basename="/">
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route
path="/home"
element={
<Layout>
<Home />
</Layout>
}
/>
<Route
path="/targets"
element={
<Layout>
<Targets />
</Layout>
}
/>
<Route
path="/templates"
element={
<Layout>
<Templates />
</Layout>
}
/>
<Route
path="/login"
element={
<LayoutLogin>
<Login />
</LayoutLogin>
}
/>
</Routes>
</Router>
);
};
export default App;

View File

@ -0,0 +1,63 @@
import React from 'react';
const Home: React.FC = () => {
return (
<div>
{/* Content Header */}
<div className="app-content-header">
<div className="container-fluid">
<div className="row">
<div className="col-sm-6">
<h3 className="mb-0">Dashboard</h3>
</div>
<div className="col-sm-6">
<ol className="breadcrumb float-sm-end">
<li className="breadcrumb-item">
<a href="#">Home</a>
</li>
<li className="breadcrumb-item active" aria-current="page">
Dashboard
</li>
</ol>
</div>
</div>
</div>
</div>
{/* Page-specific Content */}
<div className="app-content">
<div className="container-fluid">
<div className="row">
{/* Example: Small Box Widget 1 */}
<div className="col-lg-3 col-6">
<div className="small-box text-bg-primary">
<div className="inner">
<h3>150</h3>
<p>New Orders</p>
</div>
<svg
className="small-box-icon"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.25 2.25a.75.75 0 000 1.5h1.386c.17 0 .318.114.362.278l2.558 9.592a3.752 3.752 0 00-2.806 3.63c0 .414.336.75.75.75h15.75a.75.75 0 000-1.5H5.378A2.25 2.25 0 017.5 15h11.218a.75.75 0 00.674-.421 60.358 60.358 0 002.96-7.228.75.75 0 00-.525-.965A60.864 60.864 0 005.68 4.509l-.232-.867A1.875 1.875 0 003.636 2.25H2.25zM3.75 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM16.5 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z"></path>
</svg>
<a
href="#"
className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover"
>
More info <i className="bi bi-link-45deg"></i>
</a>
</div>
</div>
{/* Additional small boxes, charts, or other widgets can be included here */}
</div>
{/* You can also add more rows for charts, direct chat, etc. */}
</div>
</div>
</div>
);
};
export default Home;

View File

@ -0,0 +1,199 @@
import { useState } from 'react';
import { Button, Form, Spinner } from 'react-bootstrap';
import { AuthResponse, AuthErrorResponse, User, isAuthErrorResponse } from '@/types/auth';
//import { Helmet, HelmetProvider } from 'react-helmet-async';
import utils from '@/ts/utils.ts';
import ForgotPasswordModal from '@/components/modals/ForgotPasswordModal';
type SpinnerState = Record<string, boolean>;
type FormErrors = Record<string, string>;
function Login() {
const [isLoading, setIsLoading] = useState(false);
const [spinners, setSpinnersState] = useState<SpinnerState>({});
const [formErrors, setFormErrors] = useState<FormErrors>({});
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showForgotPasswordModal, setShowForgotPasswordModal] = useState(false);
//const [user, setUser] = useState<User | null>(null);
const [loginError, setLoginError] = useState<boolean>(false);
const [loginErrorMessage, setLoginErrorMessage] = useState<string>('');
//const setSpinners = (newValues: Partial<SpinnerState>) => {
// setSpinnersState((prevSpinners) => ({
// ...prevSpinners,
// ...newValues,
// }));
//};
const setSpinners = (newValues: Partial<SpinnerState>) => {
setSpinnersState((prevSpinners) => {
const updatedSpinners: SpinnerState = { ...prevSpinners };
for (const key in newValues) {
if (newValues[key] !== undefined) {
updatedSpinners[key] = newValues[key] as boolean;
}
}
return updatedSpinners;
});
};
const handleCloseForgotPasswordModal = () => {
setShowForgotPasswordModal(false);
};
const validateLoginForm = () => {
setFormErrors({});
const errors: FormErrors = {};
if (!username.trim()) {
errors.username = 'Username is required';
//} else if (!/\S+@\S+\.\S+/.test(email)) {
// errors.email = 'Invalid email address';
}
if (!password.trim()) {
errors.password = 'Password is required';
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
spinners.Login = true;
setSpinners(spinners);
validateLoginForm();
if (Object.keys(formErrors).length > 0) return;
//setUser(null);
setLoginError(false);
setLoginErrorMessage('');
let loggedInUser: User | null = null;
let hadLoginError: boolean = false;
let hadLoginErrorMessage: string = '';
await utils.webMethod<AuthResponse>({
methodPage: 'authentication',
methodName: 'authenticate',
parameters: { username, password },
success: (json: AuthResponse) => {
try {
loggedInUser = json.user;
//setUser(loggedInUser);
}
catch {
const errorMsg: string = "Unexpected Error";
hadLoginError = true;
hadLoginErrorMessage = errorMsg;
}
},
error: (err: unknown) => {
let errorMsg: string = "Unexpected Error";
if (isAuthErrorResponse(err)) {
if (err && err as AuthErrorResponse) {
if (err.data) {
if (err.data.message)
errorMsg = err.data.message;
}
console.error(errorMsg);
setLoginErrorMessage(errorMsg);
}
}
hadLoginError = true;
hadLoginErrorMessage = errorMsg;
}
});
if (hadLoginError) {
setLoginErrorMessage(hadLoginErrorMessage);
setLoginError(true);
setIsLoading(false);
spinners.Login = false;
setSpinners(spinners);
return;
}
if (loggedInUser == null) {
setLoginError(true);
setIsLoading(false);
spinners.Login = false;
setSpinners(spinners);
} else {
await finishUserLogin(loggedInUser);
}
};
const finishUserLogin = async (loggedInUser: User) => {
setIsLoading(false);
spinners.Login = false;
spinners.LoginWithPasskey = false;
setSpinners(spinners);
utils.localStorage("session_currentUser", loggedInUser);
const redirectUrl = utils.sessionStorage("redirect_url");
if (redirectUrl) {
utils.sessionStorage("redirect_url", null);
document.location.href = redirectUrl;
} else {
document.location.href = '/home';
}
};
return (
<div className="container">
<div className="row text-center mt-5">
<h1>surge365 - React</h1>
</div>
<div className="row text-center" style={{ maxWidth: '400px', margin: 'auto' }}>
<h3 className="form-signin-heading mt-3 mb-1">Please sign in</h3>
<Form id="frmLogin" onSubmit={handleLogin}>
{loginError && (
<Form.Label style={{ color: 'red' }}>{loginErrorMessage ?? "Login error"}</Form.Label>
)}
<Form.Group className="mb-3" controlId="txtUsernamel">
<Form.Label className="visually-hidden">Username</Form.Label>
<Form.Control
type="username"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
size="sm"
/>
{spinners.Username && <Spinner animation="border" size="sm" />}
</Form.Group>
<Form.Group className="mb-3" controlId="txtPassword">
<Form.Label className="visually-hidden">Password</Form.Label>
<Form.Control
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
size="sm"
/>
</Form.Group>
<Button className="bg-orange w-100" type="submit" disabled={isLoading}>
{spinners.Login && <Spinner animation="border" size="sm" className="me-2" />}
{isLoading && spinners.Login ? 'Signing in...' : 'Sign in'}
</Button>
<Button variant="secondary" className="w-100 mt-2" onClick={() => setShowForgotPasswordModal(true)}>
Forgot Password
</Button>
</Form>
</div>
<ForgotPasswordModal show={showForgotPasswordModal} onClose={handleCloseForgotPasswordModal} />
</div>
);
}
export default Login;

View File

@ -0,0 +1,63 @@
import React from 'react';
const Targets: React.FC = () => {
return (
<div>
{/* Content Header */}
<div className="app-content-header">
<div className="container-fluid">
<div className="row">
<div className="col-sm-6">
<h3 className="mb-0">Targets</h3>
</div>
<div className="col-sm-6">
<ol className="breadcrumb float-sm-end">
<li className="breadcrumb-item">
<a href="#">Home</a>
</li>
<li className="breadcrumb-item active" aria-current="page">
Targets
</li>
</ol>
</div>
</div>
</div>
</div>
{/* Page-specific Content */}
<div className="app-content">
<div className="container-fluid">
<div className="row">
{/* Example: Small Box Widget 1 */}
<div className="col-lg-3 col-6">
<div className="small-box text-bg-primary">
<div className="inner">
<h3>150</h3>
<p>Targets</p>
</div>
<svg
className="small-box-icon"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.25 2.25a.75.75 0 000 1.5h1.386c.17 0 .318.114.362.278l2.558 9.592a3.752 3.752 0 00-2.806 3.63c0 .414.336.75.75.75h15.75a.75.75 0 000-1.5H5.378A2.25 2.25 0 017.5 15h11.218a.75.75 0 00.674-.421 60.358 60.358 0 002.96-7.228.75.75 0 00-.525-.965A60.864 60.864 0 005.68 4.509l-.232-.867A1.875 1.875 0 003.636 2.25H2.25zM3.75 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM16.5 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z"></path>
</svg>
<a
href="#"
className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover"
>
More info <i className="bi bi-link-45deg"></i>
</a>
</div>
</div>
{/* Additional small boxes, charts, or other widgets can be included here */}
</div>
{/* You can also add more rows for charts, direct chat, etc. */}
</div>
</div>
</div>
);
};
export default Targets;

View File

@ -0,0 +1,63 @@
import React from 'react';
const Templates: React.FC = () => {
return (
<div>
{/* Content Header */}
<div className="app-content-header">
<div className="container-fluid">
<div className="row">
<div className="col-sm-6">
<h3 className="mb-0">Templates</h3>
</div>
<div className="col-sm-6">
<ol className="breadcrumb float-sm-end">
<li className="breadcrumb-item">
<a href="./home">Home</a>
</li>
<li className="breadcrumb-item active" aria-current="page">
Templates
</li>
</ol>
</div>
</div>
</div>
</div>
{/* Page-specific Content */}
<div className="app-content">
<div className="container-fluid">
<div className="row">
{/* Example: Small Box Widget 1 */}
<div className="col-lg-3 col-6">
<div className="small-box text-bg-primary">
<div className="inner">
<h3>150</h3>
<p>Templates</p>
</div>
<svg
className="small-box-icon"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.25 2.25a.75.75 0 000 1.5h1.386c.17 0 .318.114.362.278l2.558 9.592a3.752 3.752 0 00-2.806 3.63c0 .414.336.75.75.75h15.75a.75.75 0 000-1.5H5.378A2.25 2.25 0 017.5 15h11.218a.75.75 0 00.674-.421 60.358 60.358 0 002.96-7.228.75.75 0 00-.525-.965A60.864 60.864 0 005.68 4.509l-.232-.867A1.875 1.875 0 003.636 2.25H2.25zM3.75 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM16.5 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z"></path>
</svg>
<a
href="#"
className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover"
>
More info <i className="bi bi-link-45deg"></i>
</a>
</div>
</div>
{/* Additional small boxes, charts, or other widgets can be included here */}
</div>
{/* You can also add more rows for charts, direct chat, etc. */}
</div>
</div>
</div>
);
};
export default Templates;

View File

@ -0,0 +1,49 @@
import { LineChart, lineElementClasses } from '@mui/x-charts/LineChart';
import dayjs from "dayjs";
const uData = [4000, 3000, 2000, 2780, 1890, 2390, 3490]; //This is # errors
const pData = [2400, 1398, 9800, 0, 4800, 3800, 4300]; //This is # delivered above errors (delivered - errors)
const amtData = [2400, 2210, 0, 2000, 2181, 2500, 2100]; /// This is # sent above errors and delivered (sent - delivered - sent)
const time = [
new Date(2024, 7, 0),
new Date(2024, 8, 0),
new Date(2024, 9, 0),
new Date(2024, 10, 0),
new Date(2024, 11, 0),
new Date(2024, 12, 0),
new Date(2025, 1, 0)
];
export default function LineChartSample() {
return (
<LineChart
width={500}
height={300}
series={[
{ data: uData, label: 'Error', area: true, stack: 'total', showMark: false },
{ data: pData, label: 'Delivered', area: true, stack: 'total', showMark: false },
{
data: amtData,
label: 'Sent',
area: true,
stack: 'total',
showMark: false,
},
]}
xAxis={[
{
scaleType: 'time',
data: time,
min: time[0].getTime(),
max: time[time.length - 1].getTime(),
valueFormatter: (value) => dayjs(value).format("MMM YY")
},
]}
sx={{
[`& .${lineElementClasses.root}`]: {
display: 'none',
},
}}
/>
);
}

View File

@ -0,0 +1,86 @@
import { createContext, useState, useEffect, useContext } from "react";
import { Target } from "@/types/target";
import { Server } from "@/types/server";
export type SetupData = {
targets: Target[];
reloadTargets: () => void;
setTargets: (updatedTarget: Target) => void;
servers: Server[];
reloadServers: () => void;
reloadSetupData: () => void;
targetsLoading: boolean;
serversLoading: boolean;
dataLoading: boolean;
};
const SetupDataContext = createContext<SetupData | undefined>(undefined);
export const SetupDataProvider = ({ children }: { children: React.ReactNode }) => {
const [servers, setServers] = useState<Server[]>([]);
const [targets, setTargets] = useState<Target[]>([]);
const [targetsLoading, setTargetsLoading] = useState<boolean>(false);
const [serversLoading, setServersLoading] = useState<boolean>(false);
const [dataLoading, setDataLoading] = useState<boolean>(false);
const reloadSetupData = async () => {
sessionStorage.removeItem("setupData");
await fetchSetupData();
}
const fetchSetupData = async () => {
try {
setDataLoading(true);
setTargetsLoading(true);
setServersLoading(true);
const cachedData = sessionStorage.getItem("setupData");
if (cachedData) { //TODO: check if data is stale
const parsedData = JSON.parse(cachedData);
setTargets(parsedData.targets);
setServers(parsedData.servers);
setDataLoading(false);
setTargetsLoading(false);
setServersLoading(false);
return;
}
const targetsResponse = await fetch("/api/targets/GetAll?activeOnly=false");
const targetsData = await targetsResponse.json();
setTargets(targetsData);
setTargetsLoading(false);
//const serversResponse = await fetch("/api/setup/servers"); //TODO: call once setup
//const serversData = await serversResponse.json();
const serversData: Server[] = [];
setServers(serversData);
setServersLoading(false);
setDataLoading(false);
sessionStorage.setItem("setupData", JSON.stringify({ targets: targetsData, servers: serversData }));
} catch (error) {
setDataLoading(false);
setTargetsLoading(false);
setServersLoading(false);
console.error("Failed to fetch setup data:", error);
}
};
const updateTargetCache = (updatedTarget: Target) => {
setTargets((prevTargets) =>
prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
);
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
};
useEffect(() => {
fetchSetupData();
}, []);
return (
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
{children}
</SetupDataContext.Provider>
);
};
export const useSetupData = () => {
const context = useContext(SetupDataContext);
if (!context) throw new Error("useSetupData must be used within a SetupDataProvider");
return context;
};

View File

@ -0,0 +1,2 @@
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');

View File

@ -0,0 +1,3 @@
import React from 'react';
export const ColorModeContext = React.createContext({ toggleColorMode: () => { } });

View File

@ -1,3 +1,15 @@
export class ApiError extends Error {
public status: number;
public data: unknown;
constructor(status: number, data: unknown) {
super(`HTTP error! status: ${status}`);
this.status = status;
this.data = data;
Object.setPrototypeOf(this, ApiError.prototype);
}
}
const utils = {
getCookie: (name: string): string | null => {
const value = `; ${document.cookie}`;
@ -6,7 +18,7 @@ const utils = {
return null;
},
getParameterByName: (name: string): string | null => {
const regex = new RegExp(`[\?&]${name}=([^&#]*)`);
const regex = new RegExp(`[?&]${name}=([^&#]*)`);
const results = regex.exec(window.location.search);
return results ? decodeURIComponent(results[1].replace(/\+/g, ' ')) : null;
},
@ -29,26 +41,26 @@ const utils = {
return headers;
},
webMethod: async ({
webMethod: async <T = unknown>({
httpMethod = 'POST',
baseMethodPath = 'api/',
methodPage = '',
methodName = '',
parameters = {},
parameters = {} as Record<string, unknown>,
contentType = 'application/json;',
timeout = 300000,
success = () => { },
error = () => { },
success = (_data: T) => { },
error = (_err: unknown) => { },
}: {
httpMethod?: string;
baseMethodPath?: string;
methodPage?: string;
methodName?: string;
contentType?: string;
parameters?: Record<string, any>;
parameters?: Record<string, unknown>;
timeout?: number;
success?: (data: any) => void;
error?: (err: any) => void;
success?: (_data: T) => void;
error?: (_err: unknown) => void;
}): Promise<void> => {
try {
const baseUrl = window.API_BASE_URL || '';
@ -59,19 +71,24 @@ const utils = {
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
//const timeoutId = setTimeout(() => controller.abort(), timeout);
setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
method: httpMethod,
headers,
body: JSON.stringify(parameters),
body: (httpMethod.toUpperCase() == "GET" ? null : JSON.stringify(parameters)),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
let data = null;
try {
data = await response.json();
} catch {
// Intentionally empty, if not json ignore
}
throw new ApiError(response.status, data);
}
const authToken = response.headers.get('Auth-Token');

View File

@ -0,0 +1,33 @@
// src/types/auth.ts
import { UUID } from "crypto";
export interface User {
userKey: number,
userId: UUID;
username: string;
firstName: string;
middleInitial: string;
lastName: string;
isActive: boolean;
// Add any other properties returned by your API
}
export interface AuthResponse {
accessToken: string;
user: User;
}
export interface AuthErrorResponse {
message: string;
data: { message: string };
}
export function isAuthErrorResponse(err: unknown): err is AuthErrorResponse {
return (
typeof err === 'object' &&
err !== null &&
'message' in err &&
typeof (err as any).message === 'string'
);
}

View File

@ -0,0 +1,5 @@
export interface Server {
id?: number;
name: string;
}

View File

@ -0,0 +1,11 @@
export interface Target {
id?: number;
serverId: number;
name: string;
databaseName: string;
viewName: string;
filterQuery: string;
allowWriteBack: boolean;
isActive: boolean;
}

View File

@ -1,9 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": [ "ES2020", "DOM", "DOM.Iterable" ],
"module": "ESNext",
"skipLibCheck": true,
@ -22,5 +23,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": [ "src" ]
}