Configure push notifications for internal-only access
- Changed VAPID subject from public URL to mailto format - Updated docker-compose.yml to use mailto:admin@littleshop.local - Removed dependency on thebankofdebbie.giize.com public domain - All push notifications now work through VPN (admin.dark.side) only - Added update-push-internal.sh helper script for deployment - Improved security by keeping all admin traffic internal Push notifications will continue working normally through FCM, but all configuration and management stays on the internal network. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,145 +1,145 @@
|
||||
@model IEnumerable<LittleShop.DTOs.ProductDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Products";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-box"></i> Products</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<a href="@Url.Action("Blazor")" class="btn btn-success">
|
||||
<i class="fas fa-rocket"></i> <span class="d-none d-sm-inline">New</span> Blazor UI
|
||||
</a>
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Import")" class="btn btn-outline-success">
|
||||
<i class="fas fa-upload"></i> <span class="d-none d-sm-inline">Import</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Export")" class="btn btn-outline-info">
|
||||
<i class="fas fa-download"></i> <span class="d-none d-sm-inline">Export</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Price</th>
|
||||
<th>Variations</th>
|
||||
<th>Stock</th>
|
||||
<th>Weight</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var product in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (product.Photos.Any())
|
||||
{
|
||||
<img src="@product.Photos.First().FilePath" alt="@product.Photos.First().AltText" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="bg-light d-flex align-items-center justify-content-center" style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-image text-muted"></i>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<strong>@product.Name</strong>
|
||||
<br><small class="text-muted">@product.Description.Substring(0, Math.Min(50, product.Description.Length))@(product.Description.Length > 50 ? "..." : "")</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">@product.CategoryName</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>£@product.Price</strong>
|
||||
</td>
|
||||
<td>
|
||||
@if (product.MultiBuys.Any())
|
||||
{
|
||||
<span class="badge bg-info">@product.MultiBuys.Count() multi-buys</span>
|
||||
}
|
||||
@if (product.Variants.Any())
|
||||
{
|
||||
<span class="badge bg-success">@product.Variants.Count() variants</span>
|
||||
}
|
||||
@if (!product.MultiBuys.Any() && !product.Variants.Any())
|
||||
{
|
||||
<span class="text-muted">None</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (product.StockQuantity > 0)
|
||||
{
|
||||
<span class="badge bg-success">@product.StockQuantity in stock</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Out of stock</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@product.Weight @product.WeightUnit.ToString().ToLower()
|
||||
</td>
|
||||
<td>
|
||||
@if (product.IsActive)
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary" title="Edit Product">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a href="@Url.Action("Variations", new { id = product.Id })" class="btn btn-outline-info" title="Manage Multi-Buys">
|
||||
<i class="fas fa-tags"></i>
|
||||
</a>
|
||||
<a href="@Url.Action("Variants", new { id = product.Id })" class="btn btn-outline-success" title="Manage Variants">
|
||||
<i class="fas fa-palette"></i>
|
||||
</a>
|
||||
<form method="post" action="@Url.Action("Delete", new { id = product.Id })" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this product?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete Product">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-box fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No products found. <a href="@Url.Action("Create")">Create your first product</a>.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@model IEnumerable<LittleShop.DTOs.ProductDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Products";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-box"></i> Products</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<a href="@Url.Action("Blazor")" class="btn btn-success">
|
||||
<i class="fas fa-rocket"></i> <span class="d-none d-sm-inline">New</span> Blazor UI
|
||||
</a>
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Import")" class="btn btn-outline-success">
|
||||
<i class="fas fa-upload"></i> <span class="d-none d-sm-inline">Import</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Export")" class="btn btn-outline-info">
|
||||
<i class="fas fa-download"></i> <span class="d-none d-sm-inline">Export</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Price</th>
|
||||
<th>Variations</th>
|
||||
<th>Stock</th>
|
||||
<th>Weight</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var product in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (product.Photos.Any())
|
||||
{
|
||||
<img src="@product.Photos.First().FilePath" alt="@product.Photos.First().AltText" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="bg-light d-flex align-items-center justify-content-center" style="width: 50px; height: 50px;">
|
||||
<i class="fas fa-image text-muted"></i>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<strong>@product.Name</strong>
|
||||
<br><small class="text-muted">@product.Description.Substring(0, Math.Min(50, product.Description.Length))@(product.Description.Length > 50 ? "..." : "")</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">@product.CategoryName</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>£@product.Price</strong>
|
||||
</td>
|
||||
<td>
|
||||
@if (product.MultiBuys.Any())
|
||||
{
|
||||
<span class="badge bg-info">@product.MultiBuys.Count() multi-buys</span>
|
||||
}
|
||||
@if (product.Variants.Any())
|
||||
{
|
||||
<span class="badge bg-success">@product.Variants.Count() variants</span>
|
||||
}
|
||||
@if (!product.MultiBuys.Any() && !product.Variants.Any())
|
||||
{
|
||||
<span class="text-muted">None</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (product.StockQuantity > 0)
|
||||
{
|
||||
<span class="badge bg-success">@product.StockQuantity in stock</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Out of stock</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@product.Weight @product.WeightUnit.ToString().ToLower()
|
||||
</td>
|
||||
<td>
|
||||
@if (product.IsActive)
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary" title="Edit Product">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<a href="@Url.Action("Variations", new { id = product.Id })" class="btn btn-outline-info" title="Manage Multi-Buys">
|
||||
<i class="fas fa-tags"></i>
|
||||
</a>
|
||||
<a href="@Url.Action("Variants", new { id = product.Id })" class="btn btn-outline-success" title="Manage Variants">
|
||||
<i class="fas fa-palette"></i>
|
||||
</a>
|
||||
<form method="post" action="@Url.Action("Delete", new { id = product.Id })" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to delete this product?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger" title="Delete Product">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-box fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No products found. <a href="@Url.Action("Create")">Create your first product</a>.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LittleShop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVariantCollectionsAndSalesLedger : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LittleShop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVariantCollectionsAndSalesLedger : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LittleShop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWeightToProductVariants : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Weight",
|
||||
table: "ProductVariants",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WeightUnit",
|
||||
table: "ProductVariants",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "ProductVariants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WeightUnit",
|
||||
table: "ProductVariants");
|
||||
}
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LittleShop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWeightToProductVariants : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Weight",
|
||||
table: "ProductVariants",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WeightUnit",
|
||||
table: "ProductVariants",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "ProductVariants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WeightUnit",
|
||||
table: "ProductVariants");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +1,170 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates critical configuration settings on startup to prevent security issues
|
||||
/// </summary>
|
||||
public class ConfigurationValidationService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<ConfigurationValidationService> _logger;
|
||||
|
||||
public ConfigurationValidationService(
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<ConfigurationValidationService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates all critical configuration settings on startup
|
||||
/// Throws exceptions for security-critical misconfigurations
|
||||
/// </summary>
|
||||
public void ValidateConfiguration()
|
||||
{
|
||||
_logger.LogInformation("🔍 Validating application configuration...");
|
||||
|
||||
// JWT validation is critical in production, optional in development/testing
|
||||
if (_environment.IsProduction() || !string.IsNullOrEmpty(_configuration["Jwt:Key"]))
|
||||
{
|
||||
ValidateJwtConfiguration();
|
||||
}
|
||||
else if (_environment.IsDevelopment())
|
||||
{
|
||||
_logger.LogWarning("⚠️ JWT validation skipped in development. Configure Jwt:Key for production readiness.");
|
||||
}
|
||||
|
||||
ValidateSilverPayConfiguration();
|
||||
ValidateProductionSafeguards();
|
||||
ValidateEnvironmentConfiguration();
|
||||
|
||||
_logger.LogInformation("✅ Configuration validation completed successfully");
|
||||
}
|
||||
|
||||
private void ValidateJwtConfiguration()
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"];
|
||||
|
||||
if (string.IsNullOrEmpty(jwtKey))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: JWT Key not configured. Set Jwt:Key in appsettings.json");
|
||||
}
|
||||
|
||||
// Check for the old hardcoded key
|
||||
if (jwtKey.Contains("ThisIsASuperSecretKey"))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: Default JWT key detected. Generate a new secure key!");
|
||||
}
|
||||
|
||||
// Require minimum key length for security
|
||||
if (jwtKey.Length < 32)
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: JWT key too short. Must be at least 32 characters.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ JWT configuration validated");
|
||||
}
|
||||
|
||||
private void ValidateSilverPayConfiguration()
|
||||
{
|
||||
var baseUrl = _configuration["SilverPay:BaseUrl"];
|
||||
var apiKey = _configuration["SilverPay:ApiKey"];
|
||||
|
||||
if (string.IsNullOrEmpty(baseUrl))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: SilverPay BaseUrl not configured");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: SilverPay ApiKey not configured");
|
||||
}
|
||||
|
||||
// Check for test/mock indicators in production
|
||||
if (_environment.IsProduction())
|
||||
{
|
||||
if (baseUrl.Contains("localhost") || baseUrl.Contains("127.0.0.1"))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: SilverPay configured with localhost in production!");
|
||||
}
|
||||
|
||||
if (apiKey.Contains("test") || apiKey.Contains("mock") || apiKey.Contains("demo"))
|
||||
{
|
||||
_logger.LogWarning("⚠️ WARNING: SilverPay API key contains test/mock indicators in production");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ SilverPay configuration validated");
|
||||
}
|
||||
|
||||
private void ValidateProductionSafeguards()
|
||||
{
|
||||
// Ensure no mock services can be accidentally enabled
|
||||
var mockServiceConfig = _configuration.GetSection("SilverPay").GetChildren()
|
||||
.Where(x => x.Key.ToLower().Contains("mock") || x.Key.ToLower().Contains("test"))
|
||||
.ToList();
|
||||
|
||||
if (mockServiceConfig.Any())
|
||||
{
|
||||
foreach (var config in mockServiceConfig)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Found mock/test configuration: {Key} = {Value}", config.Key, config.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// In production, absolutely no mock configurations should exist
|
||||
if (_environment.IsProduction())
|
||||
{
|
||||
var useMockService = _configuration.GetValue<bool>("SilverPay:UseMockService", false);
|
||||
if (useMockService)
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: Mock service enabled in production! Set SilverPay:UseMockService to false");
|
||||
}
|
||||
|
||||
// Check for any configuration that might enable testing/mocking
|
||||
var dangerousConfigs = new[]
|
||||
{
|
||||
"Testing:Enabled",
|
||||
"Mock:Enabled",
|
||||
"Development:MockPayments",
|
||||
"Debug:MockServices"
|
||||
};
|
||||
|
||||
foreach (var configKey in dangerousConfigs)
|
||||
{
|
||||
if (_configuration.GetValue<bool>(configKey, false))
|
||||
{
|
||||
throw new InvalidOperationException($"🚨 CRITICAL: Dangerous test configuration enabled in production: {configKey}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Production safeguards validated");
|
||||
}
|
||||
|
||||
private void ValidateEnvironmentConfiguration()
|
||||
{
|
||||
// Log current environment for verification
|
||||
_logger.LogInformation("🌍 Environment: {Environment}", _environment.EnvironmentName);
|
||||
|
||||
// Validate database connection
|
||||
var connectionString = _configuration.GetConnectionString("DefaultConnection");
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: Database connection string not configured");
|
||||
}
|
||||
|
||||
// Check for development database in production
|
||||
if (_environment.IsProduction() && connectionString.Contains("littleshop.db"))
|
||||
{
|
||||
_logger.LogWarning("⚠️ WARNING: Using SQLite database in production. Consider PostgreSQL/SQL Server for production.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Environment configuration validated");
|
||||
}
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LittleShop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates critical configuration settings on startup to prevent security issues
|
||||
/// </summary>
|
||||
public class ConfigurationValidationService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<ConfigurationValidationService> _logger;
|
||||
|
||||
public ConfigurationValidationService(
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<ConfigurationValidationService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates all critical configuration settings on startup
|
||||
/// Throws exceptions for security-critical misconfigurations
|
||||
/// </summary>
|
||||
public void ValidateConfiguration()
|
||||
{
|
||||
_logger.LogInformation("🔍 Validating application configuration...");
|
||||
|
||||
// JWT validation is critical in production, optional in development/testing
|
||||
if (_environment.IsProduction() || !string.IsNullOrEmpty(_configuration["Jwt:Key"]))
|
||||
{
|
||||
ValidateJwtConfiguration();
|
||||
}
|
||||
else if (_environment.IsDevelopment())
|
||||
{
|
||||
_logger.LogWarning("⚠️ JWT validation skipped in development. Configure Jwt:Key for production readiness.");
|
||||
}
|
||||
|
||||
ValidateSilverPayConfiguration();
|
||||
ValidateProductionSafeguards();
|
||||
ValidateEnvironmentConfiguration();
|
||||
|
||||
_logger.LogInformation("✅ Configuration validation completed successfully");
|
||||
}
|
||||
|
||||
private void ValidateJwtConfiguration()
|
||||
{
|
||||
var jwtKey = _configuration["Jwt:Key"];
|
||||
|
||||
if (string.IsNullOrEmpty(jwtKey))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: JWT Key not configured. Set Jwt:Key in appsettings.json");
|
||||
}
|
||||
|
||||
// Check for the old hardcoded key
|
||||
if (jwtKey.Contains("ThisIsASuperSecretKey"))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: Default JWT key detected. Generate a new secure key!");
|
||||
}
|
||||
|
||||
// Require minimum key length for security
|
||||
if (jwtKey.Length < 32)
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: JWT key too short. Must be at least 32 characters.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ JWT configuration validated");
|
||||
}
|
||||
|
||||
private void ValidateSilverPayConfiguration()
|
||||
{
|
||||
var baseUrl = _configuration["SilverPay:BaseUrl"];
|
||||
var apiKey = _configuration["SilverPay:ApiKey"];
|
||||
|
||||
if (string.IsNullOrEmpty(baseUrl))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: SilverPay BaseUrl not configured");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: SilverPay ApiKey not configured");
|
||||
}
|
||||
|
||||
// Check for test/mock indicators in production
|
||||
if (_environment.IsProduction())
|
||||
{
|
||||
if (baseUrl.Contains("localhost") || baseUrl.Contains("127.0.0.1"))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: SilverPay configured with localhost in production!");
|
||||
}
|
||||
|
||||
if (apiKey.Contains("test") || apiKey.Contains("mock") || apiKey.Contains("demo"))
|
||||
{
|
||||
_logger.LogWarning("⚠️ WARNING: SilverPay API key contains test/mock indicators in production");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ SilverPay configuration validated");
|
||||
}
|
||||
|
||||
private void ValidateProductionSafeguards()
|
||||
{
|
||||
// Ensure no mock services can be accidentally enabled
|
||||
var mockServiceConfig = _configuration.GetSection("SilverPay").GetChildren()
|
||||
.Where(x => x.Key.ToLower().Contains("mock") || x.Key.ToLower().Contains("test"))
|
||||
.ToList();
|
||||
|
||||
if (mockServiceConfig.Any())
|
||||
{
|
||||
foreach (var config in mockServiceConfig)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Found mock/test configuration: {Key} = {Value}", config.Key, config.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// In production, absolutely no mock configurations should exist
|
||||
if (_environment.IsProduction())
|
||||
{
|
||||
var useMockService = _configuration.GetValue<bool>("SilverPay:UseMockService", false);
|
||||
if (useMockService)
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: Mock service enabled in production! Set SilverPay:UseMockService to false");
|
||||
}
|
||||
|
||||
// Check for any configuration that might enable testing/mocking
|
||||
var dangerousConfigs = new[]
|
||||
{
|
||||
"Testing:Enabled",
|
||||
"Mock:Enabled",
|
||||
"Development:MockPayments",
|
||||
"Debug:MockServices"
|
||||
};
|
||||
|
||||
foreach (var configKey in dangerousConfigs)
|
||||
{
|
||||
if (_configuration.GetValue<bool>(configKey, false))
|
||||
{
|
||||
throw new InvalidOperationException($"🚨 CRITICAL: Dangerous test configuration enabled in production: {configKey}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Production safeguards validated");
|
||||
}
|
||||
|
||||
private void ValidateEnvironmentConfiguration()
|
||||
{
|
||||
// Log current environment for verification
|
||||
_logger.LogInformation("🌍 Environment: {Environment}", _environment.EnvironmentName);
|
||||
|
||||
// Validate database connection
|
||||
var connectionString = _configuration.GetConnectionString("DefaultConnection");
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException("🚨 CRITICAL: Database connection string not configured");
|
||||
}
|
||||
|
||||
// Check for development database in production
|
||||
if (_environment.IsProduction() && connectionString.Contains("littleshop.db"))
|
||||
{
|
||||
_logger.LogWarning("⚠️ WARNING: Using SQLite database in production. Consider PostgreSQL/SQL Server for production.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Environment configuration validated");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,32 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=littleshop.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "ThisIsATemporaryKeyFor-TestingPurposesOnlyGenerateSecureKey1234567890ABCDEF",
|
||||
"Issuer": "LittleShop",
|
||||
"Audience": "LittleShop",
|
||||
"ExpiryInHours": 24
|
||||
},
|
||||
"RoyalMail": {
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"BaseUrl": "https://api.royalmail.net/",
|
||||
"SenderAddress1": "SilverLabs Ltd, 123 Business Street",
|
||||
"SenderCity": "London",
|
||||
"SenderPostCode": "SW1A 1AA",
|
||||
"SenderCountry": "United Kingdom"
|
||||
},
|
||||
"WebPush": {
|
||||
"VapidPublicKey": "BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4",
|
||||
"VapidPrivateKey": "dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY",
|
||||
"Subject": "mailto:admin@littleshop.local"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=littleshop.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "ThisIsATemporaryKeyFor-TestingPurposesOnlyGenerateSecureKey1234567890ABCDEF",
|
||||
"Issuer": "LittleShop",
|
||||
"Audience": "LittleShop",
|
||||
"ExpiryInHours": 24
|
||||
},
|
||||
"RoyalMail": {
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"BaseUrl": "https://api.royalmail.net/",
|
||||
"SenderAddress1": "SilverLabs Ltd, 123 Business Street",
|
||||
"SenderCity": "London",
|
||||
"SenderPostCode": "SW1A 1AA",
|
||||
"SenderCountry": "United Kingdom"
|
||||
},
|
||||
"WebPush": {
|
||||
"VapidPublicKey": "BMc6fFJZ8oIQKQzcl3kMnP9tTsjrm3oI_VxLt3lAGYUMWGInzDKn7jqclEoZzjvXy1QXGFb3dIun8mVBwh-QuS4",
|
||||
"VapidPrivateKey": "dYuuagbz2CzCnPDFUpO_qkGLBgnN3MEFZQnjXNkc1MY",
|
||||
"Subject": "mailto:admin@littleshop.local"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
118
LittleShop/temp_migration.sql
Normal file
118
LittleShop/temp_migration.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
BEGIN TRANSACTION;
|
||||
ALTER TABLE "Products" ADD "VariantCollectionId" TEXT NULL;
|
||||
|
||||
ALTER TABLE "Products" ADD "VariantsJson" TEXT NULL;
|
||||
|
||||
ALTER TABLE "OrderItems" ADD "SelectedVariants" TEXT NULL;
|
||||
|
||||
CREATE TABLE "SalesLedgers" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_SalesLedgers" PRIMARY KEY,
|
||||
"OrderId" TEXT NOT NULL,
|
||||
"ProductId" TEXT NOT NULL,
|
||||
"ProductName" TEXT NOT NULL,
|
||||
"Quantity" INTEGER NOT NULL,
|
||||
"SalePriceFiat" decimal(18,2) NOT NULL,
|
||||
"FiatCurrency" TEXT NOT NULL,
|
||||
"SalePriceBTC" decimal(18,8) NULL,
|
||||
"Cryptocurrency" TEXT NULL,
|
||||
"SoldAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_SalesLedgers_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_SalesLedgers_Products_ProductId" FOREIGN KEY ("ProductId") REFERENCES "Products" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE "VariantCollections" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_VariantCollections" PRIMARY KEY,
|
||||
"Name" TEXT NOT NULL,
|
||||
"PropertiesJson" TEXT NOT NULL,
|
||||
"IsActive" INTEGER NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "IX_Products_VariantCollectionId" ON "Products" ("VariantCollectionId");
|
||||
|
||||
CREATE INDEX "IX_SalesLedgers_OrderId" ON "SalesLedgers" ("OrderId");
|
||||
|
||||
CREATE INDEX "IX_SalesLedgers_ProductId" ON "SalesLedgers" ("ProductId");
|
||||
|
||||
CREATE INDEX "IX_SalesLedgers_ProductId_SoldAt" ON "SalesLedgers" ("ProductId", "SoldAt");
|
||||
|
||||
CREATE INDEX "IX_SalesLedgers_SoldAt" ON "SalesLedgers" ("SoldAt");
|
||||
|
||||
CREATE INDEX "IX_VariantCollections_IsActive" ON "VariantCollections" ("IsActive");
|
||||
|
||||
CREATE INDEX "IX_VariantCollections_Name" ON "VariantCollections" ("Name");
|
||||
|
||||
CREATE TABLE "ef_temp_OrderItems" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY,
|
||||
"OrderId" TEXT NOT NULL,
|
||||
"ProductId" TEXT NOT NULL,
|
||||
"ProductMultiBuyId" TEXT NULL,
|
||||
"Quantity" INTEGER NOT NULL,
|
||||
"SelectedVariants" TEXT NULL,
|
||||
"TotalPrice" decimal(18,2) NOT NULL,
|
||||
"UnitPrice" decimal(18,2) NOT NULL,
|
||||
CONSTRAINT "FK_OrderItems_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_OrderItems_ProductMultiBuys_ProductMultiBuyId" FOREIGN KEY ("ProductMultiBuyId") REFERENCES "ProductMultiBuys" ("Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_OrderItems_Products_ProductId" FOREIGN KEY ("ProductId") REFERENCES "Products" ("Id") ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
INSERT INTO "ef_temp_OrderItems" ("Id", "OrderId", "ProductId", "ProductMultiBuyId", "Quantity", "SelectedVariants", "TotalPrice", "UnitPrice")
|
||||
SELECT "Id", "OrderId", "ProductId", "ProductMultiBuyId", "Quantity", "SelectedVariants", "TotalPrice", "UnitPrice"
|
||||
FROM "OrderItems";
|
||||
|
||||
CREATE TABLE "ef_temp_Products" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_Products" PRIMARY KEY,
|
||||
"CategoryId" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"Description" TEXT NOT NULL,
|
||||
"IsActive" INTEGER NOT NULL,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Price" decimal(18,2) NOT NULL,
|
||||
"StockQuantity" INTEGER NOT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL,
|
||||
"VariantCollectionId" TEXT NULL,
|
||||
"VariantsJson" TEXT NULL,
|
||||
"Weight" decimal(18,4) NOT NULL,
|
||||
"WeightUnit" INTEGER NOT NULL,
|
||||
CONSTRAINT "FK_Products_Categories_CategoryId" FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_Products_VariantCollections_VariantCollectionId" FOREIGN KEY ("VariantCollectionId") REFERENCES "VariantCollections" ("Id")
|
||||
);
|
||||
|
||||
INSERT INTO "ef_temp_Products" ("Id", "CategoryId", "CreatedAt", "Description", "IsActive", "Name", "Price", "StockQuantity", "UpdatedAt", "VariantCollectionId", "VariantsJson", "Weight", "WeightUnit")
|
||||
SELECT "Id", "CategoryId", "CreatedAt", "Description", "IsActive", "Name", "Price", "StockQuantity", "UpdatedAt", "VariantCollectionId", "VariantsJson", "Weight", "WeightUnit"
|
||||
FROM "Products";
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = 0;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
DROP TABLE "OrderItems";
|
||||
|
||||
ALTER TABLE "ef_temp_OrderItems" RENAME TO "OrderItems";
|
||||
|
||||
DROP TABLE "Products";
|
||||
|
||||
ALTER TABLE "ef_temp_Products" RENAME TO "Products";
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = 1;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
CREATE INDEX "IX_OrderItems_OrderId" ON "OrderItems" ("OrderId");
|
||||
|
||||
CREATE INDEX "IX_OrderItems_ProductId" ON "OrderItems" ("ProductId");
|
||||
|
||||
CREATE INDEX "IX_OrderItems_ProductMultiBuyId" ON "OrderItems" ("ProductMultiBuyId");
|
||||
|
||||
CREATE INDEX "IX_Products_CategoryId" ON "Products" ("CategoryId");
|
||||
|
||||
CREATE INDEX "IX_Products_VariantCollectionId" ON "Products" ("VariantCollectionId");
|
||||
|
||||
COMMIT;
|
||||
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ('20250928014546_AddVariantCollectionsAndSalesLedger', '9.0.9');
|
||||
|
||||
@@ -1,221 +1,221 @@
|
||||
// Modern Mobile Enhancements
|
||||
// Clean, simple mobile-friendly functionality
|
||||
|
||||
class ModernMobile {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupMobileTableLabels();
|
||||
this.setupResponsiveNavigation();
|
||||
this.setupFormEnhancements();
|
||||
this.setupSmoothInteractions();
|
||||
}
|
||||
|
||||
// Add data labels for mobile table stacking
|
||||
setupMobileTableLabels() {
|
||||
const tables = document.querySelectorAll('.table');
|
||||
|
||||
tables.forEach(table => {
|
||||
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
if (headers[index]) {
|
||||
cell.setAttribute('data-label', headers[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced mobile navigation
|
||||
setupResponsiveNavigation() {
|
||||
const navbar = document.querySelector('.navbar');
|
||||
const toggler = document.querySelector('.navbar-toggler');
|
||||
const collapse = document.querySelector('.navbar-collapse');
|
||||
|
||||
if (toggler && collapse) {
|
||||
// Smooth collapse animation
|
||||
toggler.addEventListener('click', () => {
|
||||
collapse.classList.toggle('show');
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!navbar.contains(e.target) && collapse.classList.contains('show')) {
|
||||
collapse.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Form enhancements for better mobile UX
|
||||
setupFormEnhancements() {
|
||||
// Auto-focus first input on desktop
|
||||
if (window.innerWidth > 768) {
|
||||
const firstInput = document.querySelector('.form-control:not([readonly]):not([disabled])');
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced form validation feedback
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
// Skip validation for file upload forms
|
||||
if (form.enctype === 'multipart/form-data') {
|
||||
console.log('Mobile: Skipping validation for file upload form');
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidInputs = form.querySelectorAll(':invalid');
|
||||
if (invalidInputs.length > 0) {
|
||||
e.preventDefault();
|
||||
console.log('Mobile: Form validation failed, focusing on first invalid input');
|
||||
invalidInputs[0].focus();
|
||||
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Floating labels effect
|
||||
const inputs = document.querySelectorAll('.form-control, .form-select');
|
||||
inputs.forEach(input => {
|
||||
if (input.value) {
|
||||
input.parentElement.classList.add('has-value');
|
||||
}
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value) {
|
||||
input.parentElement.classList.add('has-value');
|
||||
} else {
|
||||
input.parentElement.classList.remove('has-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Smooth interactions and feedback
|
||||
setupSmoothInteractions() {
|
||||
// Button click feedback
|
||||
const buttons = document.querySelectorAll('.btn');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
if (!button.disabled) {
|
||||
button.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
button.style.transform = '';
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Card hover enhancement
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.transform = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
const anchorLinks = document.querySelectorAll('a[href^="#"]');
|
||||
anchorLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(link.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
showToast(message, type = 'info') {
|
||||
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
toast.style.cssText = `
|
||||
margin-bottom: 0.5rem;
|
||||
animation: slideInRight 0.3s ease;
|
||||
`;
|
||||
|
||||
toast.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" aria-label="Close"></button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Auto-remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 150);
|
||||
}, 4000);
|
||||
|
||||
// Manual close
|
||||
const closeBtn = toast.querySelector('.btn-close');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
toast.classList.add('fade');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
max-width: 300px;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
// CSS for toast animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.15s linear !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Initialize
|
||||
const modernMobile = new ModernMobile();
|
||||
// Modern Mobile Enhancements
|
||||
// Clean, simple mobile-friendly functionality
|
||||
|
||||
class ModernMobile {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupMobileTableLabels();
|
||||
this.setupResponsiveNavigation();
|
||||
this.setupFormEnhancements();
|
||||
this.setupSmoothInteractions();
|
||||
}
|
||||
|
||||
// Add data labels for mobile table stacking
|
||||
setupMobileTableLabels() {
|
||||
const tables = document.querySelectorAll('.table');
|
||||
|
||||
tables.forEach(table => {
|
||||
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
cells.forEach((cell, index) => {
|
||||
if (headers[index]) {
|
||||
cell.setAttribute('data-label', headers[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced mobile navigation
|
||||
setupResponsiveNavigation() {
|
||||
const navbar = document.querySelector('.navbar');
|
||||
const toggler = document.querySelector('.navbar-toggler');
|
||||
const collapse = document.querySelector('.navbar-collapse');
|
||||
|
||||
if (toggler && collapse) {
|
||||
// Smooth collapse animation
|
||||
toggler.addEventListener('click', () => {
|
||||
collapse.classList.toggle('show');
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!navbar.contains(e.target) && collapse.classList.contains('show')) {
|
||||
collapse.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Form enhancements for better mobile UX
|
||||
setupFormEnhancements() {
|
||||
// Auto-focus first input on desktop
|
||||
if (window.innerWidth > 768) {
|
||||
const firstInput = document.querySelector('.form-control:not([readonly]):not([disabled])');
|
||||
if (firstInput) {
|
||||
firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced form validation feedback
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
// Skip validation for file upload forms
|
||||
if (form.enctype === 'multipart/form-data') {
|
||||
console.log('Mobile: Skipping validation for file upload form');
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidInputs = form.querySelectorAll(':invalid');
|
||||
if (invalidInputs.length > 0) {
|
||||
e.preventDefault();
|
||||
console.log('Mobile: Form validation failed, focusing on first invalid input');
|
||||
invalidInputs[0].focus();
|
||||
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Floating labels effect
|
||||
const inputs = document.querySelectorAll('.form-control, .form-select');
|
||||
inputs.forEach(input => {
|
||||
if (input.value) {
|
||||
input.parentElement.classList.add('has-value');
|
||||
}
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value) {
|
||||
input.parentElement.classList.add('has-value');
|
||||
} else {
|
||||
input.parentElement.classList.remove('has-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Smooth interactions and feedback
|
||||
setupSmoothInteractions() {
|
||||
// Button click feedback
|
||||
const buttons = document.querySelectorAll('.btn');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
if (!button.disabled) {
|
||||
button.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
button.style.transform = '';
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Card hover enhancement
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.transform = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
const anchorLinks = document.querySelectorAll('a[href^="#"]');
|
||||
anchorLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(link.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
showToast(message, type = 'info') {
|
||||
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
toast.style.cssText = `
|
||||
margin-bottom: 0.5rem;
|
||||
animation: slideInRight 0.3s ease;
|
||||
`;
|
||||
|
||||
toast.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" aria-label="Close"></button>
|
||||
`;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Auto-remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 150);
|
||||
}, 4000);
|
||||
|
||||
// Manual close
|
||||
const closeBtn = toast.querySelector('.btn-close');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
toast.classList.add('fade');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
max-width: 300px;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
// CSS for toast animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.15s linear !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Initialize
|
||||
const modernMobile = new ModernMobile();
|
||||
window.modernMobile = modernMobile;
|
||||
281
LittleShop/wwwroot/service-worker.js
Normal file
281
LittleShop/wwwroot/service-worker.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// Service Worker for LittleShop Admin PWA
|
||||
const CACHE_NAME = 'littleshop-admin-v1';
|
||||
const urlsToCache = [
|
||||
'/Admin/Dashboard',
|
||||
'/manifest.json',
|
||||
'/lib/bootstrap/css/bootstrap.min.css',
|
||||
'/lib/fontawesome/css/all.min.css',
|
||||
'/css/modern-admin.css',
|
||||
'/css/mobile-admin.css',
|
||||
'/lib/jquery/jquery.min.js',
|
||||
'/lib/bootstrap/js/bootstrap.bundle.min.js',
|
||||
'/js/pwa.js',
|
||||
'/js/notifications.js',
|
||||
'/icons/icon-192x192.png',
|
||||
'/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
// Install event - cache essential files
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Service Worker: Caching essential files');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(cacheName => cacheName !== CACHE_NAME)
|
||||
.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - network first, fallback to cache
|
||||
self.addEventListener('fetch', event => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip API requests from cache
|
||||
if (event.request.url.includes('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => {
|
||||
// If offline and it's an API request, return offline message
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Offline - Please check your connection' }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For everything else, try network first, then cache
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response => {
|
||||
// Don't cache if not a successful response
|
||||
if (!response || response.status !== 200 || response.type === 'error') {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the response as it can only be consumed once
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// If network fails, try cache
|
||||
return caches.match(event.request)
|
||||
.then(response => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
// If not in cache, return offline page for navigation requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/offline.html');
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Push notification event
|
||||
self.addEventListener('push', event => {
|
||||
console.log('Push notification received:', event);
|
||||
|
||||
let notificationData = {
|
||||
title: 'LittleShop Admin',
|
||||
body: 'You have a new notification',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
badge: '/icons/icon-96x96.png',
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
}
|
||||
};
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const data = event.data.json();
|
||||
notificationData = {
|
||||
title: data.title || notificationData.title,
|
||||
body: data.body || notificationData.body,
|
||||
icon: data.icon || notificationData.icon,
|
||||
badge: data.badge || notificationData.badge,
|
||||
vibrate: data.vibrate || notificationData.vibrate,
|
||||
data: data.data || notificationData.data,
|
||||
tag: data.tag,
|
||||
requireInteraction: data.requireInteraction || false,
|
||||
actions: data.actions || []
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error parsing push data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(notificationData.title, notificationData)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click event
|
||||
self.addEventListener('notificationclick', event => {
|
||||
console.log('Notification clicked:', event);
|
||||
event.notification.close();
|
||||
|
||||
// Handle action clicks
|
||||
if (event.action) {
|
||||
switch (event.action) {
|
||||
case 'view':
|
||||
clients.openWindow(event.notification.data.url || '/Admin/Dashboard');
|
||||
break;
|
||||
case 'dismiss':
|
||||
// Just close the notification
|
||||
break;
|
||||
default:
|
||||
clients.openWindow('/Admin/Dashboard');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default click - open the app or focus if already open
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then(clientList => {
|
||||
// Check if there's already a window open
|
||||
for (let client of clientList) {
|
||||
if (client.url.includes('/Admin/') && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// If no window is open, open a new one
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(event.notification.data?.url || '/Admin/Dashboard');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for offline actions
|
||||
self.addEventListener('sync', event => {
|
||||
console.log('Background sync triggered:', event.tag);
|
||||
|
||||
if (event.tag === 'sync-orders') {
|
||||
event.waitUntil(syncOrders());
|
||||
} else if (event.tag === 'sync-products') {
|
||||
event.waitUntil(syncProducts());
|
||||
}
|
||||
});
|
||||
|
||||
// Sync orders when back online
|
||||
async function syncOrders() {
|
||||
try {
|
||||
// Get any pending orders from IndexedDB
|
||||
const db = await openDB();
|
||||
const tx = db.transaction('pending-orders', 'readonly');
|
||||
const store = tx.objectStore('pending-orders');
|
||||
const pendingOrders = await store.getAll();
|
||||
|
||||
for (const order of pendingOrders) {
|
||||
await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(order)
|
||||
});
|
||||
|
||||
// Remove from pending after successful sync
|
||||
const deleteTx = db.transaction('pending-orders', 'readwrite');
|
||||
await deleteTx.objectStore('pending-orders').delete(order.id);
|
||||
}
|
||||
|
||||
// Show notification of successful sync
|
||||
self.registration.showNotification('Orders Synced', {
|
||||
body: `${pendingOrders.length} orders synchronized successfully`,
|
||||
icon: '/icons/icon-192x192.png'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error syncing orders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync products when back online
|
||||
async function syncProducts() {
|
||||
try {
|
||||
const response = await fetch('/api/catalog/products');
|
||||
if (response.ok) {
|
||||
const products = await response.json();
|
||||
|
||||
// Update cached products
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
await cache.put('/api/catalog/products', new Response(JSON.stringify(products)));
|
||||
|
||||
console.log('Products synced successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing products:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to open IndexedDB
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('LittleShopDB', 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = event => {
|
||||
const db = event.target.result;
|
||||
|
||||
if (!db.objectStoreNames.contains('pending-orders')) {
|
||||
db.createObjectStore('pending-orders', { keyPath: 'id', autoIncrement: true });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains('pending-products')) {
|
||||
db.createObjectStore('pending-products', { keyPath: 'id', autoIncrement: true });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Message event for communication with the app
|
||||
self.addEventListener('message', event => {
|
||||
console.log('Service Worker received message:', event.data);
|
||||
|
||||
if (event.data.action === 'skipWaiting') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data.action === 'clearCache') {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
}).then(() => {
|
||||
event.ports[0].postMessage({ success: true });
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Service Worker loaded successfully');
|
||||
Reference in New Issue
Block a user