Refactor authentication and layout structure
- Updated `AuthenticationController` to handle refresh tokens and store them in the database. - Modified `IAuthService` and `IUserRepository` to support new authentication logic using refresh tokens. - Removed `GenerateTokens` method and added `SaveRefreshToken` method. - Adjusted database mappings in `UserMap`. - Enhanced `AuthService` to implement new authentication flow. - Removed `LayoutAdminLTE.tsx`, simplifying the layout structure. - Improved theme initialization in `App.tsx` for persistent settings. - Cleaned up component files (`Home`, `Login`, `Targets`, `Templates`) by removing boilerplate code. - Added `eslint.config.js` for ESLint configuration. - Introduced `ThemeWrapper.tsx` for managing theme settings (implementation commented out).
This commit is contained in:
parent
f5b1fe6397
commit
5e85b9e596
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Surge365.MassEmailReact.API.Controllers;
|
||||
using Surge365.MassEmailReact.Application.DTOs;
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
|
||||
namespace Surge365.MassEmailReact.API.Controllers
|
||||
{
|
||||
@ -38,6 +39,7 @@ namespace Surge365.MassEmailReact.API.Controllers
|
||||
else if(authResponse.data == null)
|
||||
return Unauthorized(new { message = "Invalid credentials" });
|
||||
|
||||
//TODO: Store refresh token in DB
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true, // Prevents JavaScript access (mitigates XSS)
|
||||
@ -58,15 +60,12 @@ namespace Surge365.MassEmailReact.API.Controllers
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
return Unauthorized("Invalid refresh token");
|
||||
|
||||
Guid? userId = Guid.Parse("B077E02E-7383-4942-B57D-F2DFA9D33B8E");//TODO: Lookup user in session by refresh token
|
||||
if (userId == null)
|
||||
{
|
||||
return Unauthorized("Invalid refresh token");
|
||||
}
|
||||
var authResponse = await _authService.Authenticate(refreshToken);
|
||||
if (!authResponse.authenticated)
|
||||
return Unauthorized(new { message = authResponse.errorMessage });
|
||||
else if (authResponse.data == null)
|
||||
return Unauthorized(new { message = "Invalid credentials" });
|
||||
|
||||
var tokens = await _authService.GenerateTokens(userId.Value, refreshToken);
|
||||
if(tokens == null)
|
||||
return Unauthorized("Invalid refresh token");
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
@ -74,9 +73,9 @@ namespace Surge365.MassEmailReact.API.Controllers
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(7)
|
||||
};
|
||||
Response.Cookies.Append("refreshToken", tokens.Value.refreshToken, cookieOptions);
|
||||
Response.Cookies.Append("refreshToken", authResponse.data.Value.refreshToken, cookieOptions);
|
||||
|
||||
return Ok(new { accessToken = tokens.Value.accessToken });
|
||||
return Ok(new { accessToken = authResponse.data.Value.accessToken });
|
||||
}
|
||||
[HttpPost("generatepasswordrecovery")]
|
||||
public IActionResult GeneratePasswordRecovery([FromBody] GeneratePasswordRecoveryRequest request)
|
||||
|
||||
@ -5,6 +5,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password);
|
||||
Task<(string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string refreshToken);
|
||||
Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,10 +10,12 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<(User? user, string message)> Authenticate(string username, string password);
|
||||
bool Authenticate(Guid userId, string refreshToken);
|
||||
Task<User?> Authenticate(string refreshToken);
|
||||
Task<User?> GetByUsername(string username);
|
||||
Task<User?> GetByKey(int userKey);
|
||||
Task<User?> GetById(Guid userId);
|
||||
Task<List<User>> GetAll(bool activeOnly = true);
|
||||
|
||||
Task<bool> SaveRefreshToken(Guid userId, string refreshToken, string? previousToken = "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
|
||||
public UserMap()
|
||||
{
|
||||
Map(u => u.UserKey).ToColumn("login_key");
|
||||
Map(u => u.UserId).ToColumn("session_activity_id"); // Assuming this is the Guid mapping
|
||||
Map(u => u.UserId).ToColumn("login_id");
|
||||
Map(u => u.Username).ToColumn("username");
|
||||
Map(u => u.FirstName).ToColumn("first_name");
|
||||
Map(u => u.MiddleInitial).ToColumn("middle_initial");
|
||||
|
||||
@ -16,6 +16,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private const string _connectionStringName = "Marketing.ConnectionString";
|
||||
private const string _refreshTokenConnectionStringName = "MassEmail.ConnectionString";
|
||||
|
||||
public UserRepository(IConfiguration config)
|
||||
{
|
||||
@ -31,6 +32,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
}
|
||||
|
||||
private string ConnectionString => _config.GetConnectionString(_connectionStringName) ?? "";
|
||||
private string RefreshTokenConnectionString => _config.GetConnectionString(_refreshTokenConnectionStringName) ?? "";
|
||||
|
||||
public async Task<(User? user, string message)> Authenticate(string username, string password)
|
||||
{
|
||||
@ -60,10 +62,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
return (null, responseMessage);
|
||||
}
|
||||
|
||||
public bool Authenticate(Guid userId, string refreshToken)
|
||||
public async Task<User?> Authenticate(string refreshToken)
|
||||
{
|
||||
// TODO: Validate refresh token
|
||||
return true;
|
||||
using var connection = new SqlConnection(RefreshTokenConnectionString);
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("@@token_hash", HashToken(refreshToken));
|
||||
parameters.Add("@valid", dbType: DbType.Boolean, direction: ParameterDirection.Output);
|
||||
parameters.Add("@user_guid", dbType: DbType.Guid, direction: ParameterDirection.Output);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"mem_validate_user_refresh_token",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
|
||||
if (!parameters.Get<bool>("@valid"))
|
||||
return null;
|
||||
|
||||
return await GetById(parameters.Get<Guid>("@user_guid"));
|
||||
}
|
||||
|
||||
public async Task<User?> GetByUsername(string username)
|
||||
@ -110,5 +126,42 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
);
|
||||
return users.AsList();
|
||||
}
|
||||
public async Task<bool> SaveRefreshToken(Guid userId, string token, string? previousToken = "")
|
||||
{
|
||||
using var connection = new SqlConnection(RefreshTokenConnectionString);
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("@user_guid", userId);
|
||||
parameters.Add("@token_hash", HashToken(token));
|
||||
parameters.Add("@previous_token_hash", HashToken(previousToken ?? ""));
|
||||
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"mem_save_user_refresh_token",
|
||||
parameters,
|
||||
commandType: CommandType.StoredProcedure
|
||||
);
|
||||
|
||||
return parameters.Get<bool>("@success");
|
||||
}
|
||||
|
||||
private string HashToken(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
using (var sha256 = System.Security.Cryptography.SHA256.Create())
|
||||
{
|
||||
// Convert the token string to bytes
|
||||
byte[] tokenBytes = System.Text.Encoding.UTF8.GetBytes(token);
|
||||
|
||||
// Compute the hash
|
||||
byte[] hashBytes = sha256.ComputeHash(tokenBytes);
|
||||
|
||||
// Convert the hash to a hexadecimal string
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,19 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
_userRepository = userRepository;
|
||||
_config = config;
|
||||
}
|
||||
public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string refreshToken)
|
||||
{
|
||||
var user = await _userRepository.Authenticate(refreshToken);
|
||||
if (user == null)
|
||||
return (false, null, "Not authenticated");
|
||||
|
||||
var tokenResponse = await GenerateTokens(user.UserId, refreshToken);
|
||||
if(tokenResponse == null)
|
||||
return (false, null, "Error generating tokens");
|
||||
|
||||
return (true, tokenResponse, "");
|
||||
|
||||
}
|
||||
|
||||
public async Task<(bool authenticated, (User user, string accessToken, string refreshToken)? data, string errorMessage)> Authenticate(string username, string password)
|
||||
{
|
||||
@ -56,17 +69,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
var accessToken = tokenHandler.WriteToken(token);
|
||||
var refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
|
||||
//_userRepository.SaveRefreshToken(userId.Value, refreshToken); // TODO: Store refresh token in DB
|
||||
string refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
|
||||
if (!await _userRepository.SaveRefreshToken(authResponse.user.UserId, refreshToken))
|
||||
return (false, null, "Error saving token");
|
||||
|
||||
return (true, (authResponse.user, accessToken, refreshToken), "");
|
||||
}
|
||||
public async Task<(string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string refreshToken)
|
||||
private async Task<(User user, string accessToken, string refreshToken)?> GenerateTokens(Guid userId, string previousRefreshToken)
|
||||
{
|
||||
if (!_userRepository.Authenticate(userId, refreshToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var user = await _userRepository.GetById(userId);
|
||||
if (user == null)
|
||||
{
|
||||
@ -82,7 +92,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
//TODO: Look update User
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
@ -96,9 +106,10 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
var accessToken = tokenHandler.WriteToken(token);
|
||||
var newRefreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray());
|
||||
//_userRepository.SaveRefreshToken(userId.Value, newRefreshToken); // TODO: Store refresh token in DB
|
||||
if (!await _userRepository.SaveRefreshToken(user.UserId, newRefreshToken, previousRefreshToken))
|
||||
return null;
|
||||
|
||||
return (accessToken, newRefreshToken);
|
||||
return (user, accessToken, newRefreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import utils from '@/ts/utils';
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
|
||||
@ -79,13 +79,13 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)', // Optional: light overlay
|
||||
zIndex: 9999, // Ensure it’s above everything
|
||||
zIndex: 9999, // Ensure it’s above everything
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
size={80} // Larger spinner
|
||||
thickness={4} // Slightly thicker for visibility
|
||||
sx={{ color: 'primary.main' }} // Use theme’s primary color
|
||||
sx={{ color: 'primary.main' }} // Use theme’s primary color
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
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 © 2025
|
||||
<a href="https://adminlte.io" className="text-decoration-none">Surge365</a>.
|
||||
</strong>
|
||||
All rights reserved.
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@ -69,7 +69,10 @@ const getTheme = (mode: 'light' | 'dark'): Theme => {
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
|
||||
const [mode, setMode] = React.useState<'light' | 'dark'>(() => {
|
||||
const savedMode = localStorage.getItem('mui-mode');
|
||||
return (savedMode === 'light' || savedMode === 'dark') ? savedMode : 'light';
|
||||
});
|
||||
|
||||
const colorMode = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
// src/components/pages/Home.tsx
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid2 from '@mui/material/Grid2'; // v6 Grid2
|
||||
|
||||
import RecentMailingStatsChart from '@/components/widgets/RecentMailingStatsChart';
|
||||
|
||||
@ -10,9 +8,6 @@ const Home = () => {
|
||||
<Box sx={{
|
||||
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", maxHeight: "700px", overflow: "hidden",
|
||||
}}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Sent Mailing Statistics
|
||||
</Typography>
|
||||
<RecentMailingStatsChart days={14} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
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');
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
// 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;
|
||||
@ -1,63 +0,0 @@
|
||||
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;
|
||||
@ -1,199 +0,0 @@
|
||||
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;
|
||||
@ -1,63 +0,0 @@
|
||||
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;
|
||||
@ -1,63 +0,0 @@
|
||||
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;
|
||||
@ -0,0 +1,10 @@
|
||||
// This file allows you to configure ESLint according to your project's needs, so that you
|
||||
// can control the strictness of the linter, the plugins to use, and more.
|
||||
|
||||
// For more information about configuring ESLint, visit https://eslint.org/docs/user-guide/configuring/
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
rules: {}
|
||||
}
|
||||
];
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { BarChart } from '@mui/x-charts/BarChart';
|
||||
import dayjs from 'dayjs';
|
||||
import MailingStatistic from '@/types/mailingStatistic';
|
||||
@ -8,9 +8,11 @@ export default function RecentMailingStatsChart({ days = 7 }: { days?: number })
|
||||
const [stats, setStats] = useState<MailingStatistic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
var startDate = dayjs().subtract(days, 'day').format('YYYY-MM-DD');
|
||||
var endDate = dayjs().format('YYYY-MM-DD');
|
||||
const startDate = dayjs().subtract(days, 'day').format('YYYY-MM-DD');
|
||||
const endDate = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
//startDate = '2025-02-12';
|
||||
//endDate = '2025-02-26'
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/mailings/status/s%2Csd/stats?startDate=${startDate}&endDate=${endDate}`);
|
||||
@ -33,10 +35,6 @@ export default function RecentMailingStatsChart({ days = 7 }: { days?: number })
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>Loading...</Box>;
|
||||
}
|
||||
|
||||
if (!stats.length) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>No data available</Box>;
|
||||
}
|
||||
|
||||
// Aggregate stats by date
|
||||
const aggregatedStats = stats.reduce((acc, stat) => {
|
||||
const dateStr = stat.sentDate ? dayjs(stat.sentDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
@ -72,7 +70,12 @@ export default function RecentMailingStatsChart({ days = 7 }: { days?: number })
|
||||
));
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
|
||||
<Typography variant="h5" component="h1" gutterBottom>
|
||||
Sent Mailings - Last 14 Days
|
||||
</Typography>
|
||||
<BarChart
|
||||
title="Sent Mailings - Last 14 Days"
|
||||
margin={{
|
||||
left: 80,
|
||||
right: 80,
|
||||
@ -104,5 +107,6 @@ export default function RecentMailingStatsChart({ days = 7 }: { days?: number })
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
66
Surge365.MassEmailReact.Web/src/theme/ThemeWrapper.tsx
Normal file
66
Surge365.MassEmailReact.Web/src/theme/ThemeWrapper.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
//// src/components/ThemeWrapper.tsx
|
||||
//import React from 'react';
|
||||
//import { createTheme, ThemeProvider, Theme } from '@mui/material/styles';
|
||||
//import CssBaseline from '@mui/material/CssBaseline';
|
||||
//import { ColorModeContext } from '@/theme/theme';
|
||||
|
||||
//interface ThemeWrapperProps {
|
||||
// children: React.ReactNode;
|
||||
//}
|
||||
|
||||
//const getTheme = (mode: 'light' | 'dark'): Theme => {
|
||||
// console.log('getTheme called with mode:', mode); // Debug log
|
||||
// return createTheme({
|
||||
// palette: {
|
||||
// mode,
|
||||
// },
|
||||
// cssVariables: {
|
||||
// colorSchemeSelector: 'class',
|
||||
// },
|
||||
// colorSchemes: {
|
||||
// light: true,
|
||||
// dark: true,
|
||||
// },
|
||||
// components: {
|
||||
// MuiAutocomplete: {
|
||||
// defaultProps: {
|
||||
// handleHomeEndKeys: false,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
//};
|
||||
|
||||
//// src/components/ThemeWrapper.tsx
|
||||
//export const ThemeWrapper: React.FC<ThemeWrapperProps> = ({ children }) => {
|
||||
// const [mode, setMode] = React.useState<'light' | 'dark'>(() => {
|
||||
// const savedMode = localStorage.getItem('themeMode');
|
||||
// return (savedMode === 'light' || savedMode === 'dark') ? savedMode : 'light';
|
||||
// });
|
||||
|
||||
// const colorMode = React.useMemo(
|
||||
// () => ({
|
||||
// mode, // Include mode in the context
|
||||
// toggleColorMode: () => {
|
||||
// setMode((prevMode) => {
|
||||
// const newMode = prevMode === 'light' ? 'dark' : 'light';
|
||||
// localStorage.setItem('themeMode', newMode);
|
||||
// console.log('Toggled mode to:', newMode);
|
||||
// return newMode;
|
||||
// });
|
||||
// },
|
||||
// }),
|
||||
// [mode] // Add mode as a dependency
|
||||
// );
|
||||
|
||||
// const theme = React.useMemo(() => getTheme(mode), [mode]);
|
||||
|
||||
// return (
|
||||
// <ColorModeContext.Provider value={colorMode}>
|
||||
// <ThemeProvider theme={theme}>
|
||||
// <CssBaseline />
|
||||
// {children}
|
||||
// </ThemeProvider>
|
||||
// </ColorModeContext.Provider>
|
||||
// );
|
||||
//};
|
||||
Loading…
x
Reference in New Issue
Block a user