diff --git a/DEPLOYMENT_NGINX_GUIDE.md b/DEPLOYMENT_NGINX_GUIDE.md new file mode 100644 index 0000000..30006ca --- /dev/null +++ b/DEPLOYMENT_NGINX_GUIDE.md @@ -0,0 +1,448 @@ +# Nginx Deployment Guide - Push Notification Security + +This guide explains how to properly configure nginx to isolate the admin panel while keeping push notifications functional. + +## Architecture Overview + +``` +Internet + │ + ├─── api.littleshop.com (Public API) + │ └─── Only: /api/push/vapid-key, /api/push/subscribe/customer, /api/push/unsubscribe + │ + └─── admin.dark.side (LAN-Only Admin Panel) + └─── Full access: /Admin/*, /api/*, /blazor/* + +LittleShop Backend (littleshop:5000) +``` + +## Complete Nginx Configuration + +### Step 1: Create upstream backend + +```nginx +# /etc/nginx/conf.d/littleshop-upstream.conf +upstream littleshop_backend { + server littleshop:5000; + keepalive 32; +} +``` + +### Step 2: Public API Server (Internet-Accessible) + +```nginx +# /etc/nginx/sites-available/littleshop-public-api.conf + +# Rate limiting zones +limit_req_zone $binary_remote_addr zone=push_vapid:10m rate=60r/m; +limit_req_zone $binary_remote_addr zone=push_subscribe:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=push_unsubscribe:10m rate=20r/m; + +server { + listen 80; + listen [::]:80; + server_name api.littleshop.com; + + # Redirect to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.littleshop.com; + + # SSL configuration + ssl_certificate /etc/letsencrypt/live/api.littleshop.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/api.littleshop.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # VAPID public key endpoint (required for push notifications) + location = /api/push/vapid-key { + limit_req zone=push_vapid burst=10; + + proxy_pass http://littleshop_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS headers for push notifications + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + + # Preflight requests + if ($request_method = OPTIONS) { + return 204; + } + } + + # Customer subscription endpoint + location = /api/push/subscribe/customer { + limit_req zone=push_subscribe burst=5; + + proxy_pass http://littleshop_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS headers + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + + if ($request_method = OPTIONS) { + return 204; + } + } + + # Unsubscribe endpoint + location = /api/push/unsubscribe { + limit_req zone=push_unsubscribe burst=10; + + proxy_pass http://littleshop_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS headers + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + + if ($request_method = OPTIONS) { + return 204; + } + } + + # Block all other endpoints + location / { + return 403 '{"error": "Access denied", "message": "Admin access is restricted to authorized networks"}'; + add_header Content-Type application/json; + } + + # Custom error pages + error_page 403 /403.json; + location = /403.json { + internal; + return 403 '{"error": "Access denied", "message": "This endpoint is not publicly accessible"}'; + add_header Content-Type application/json; + } +} +``` + +### Step 3: Admin Panel Server (LAN-Only) + +```nginx +# /etc/nginx/sites-available/littleshop-admin.conf + +server { + listen 80; + listen [::]:80; + server_name admin.dark.side admin.littleshop.local; + + # Redirect to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name admin.dark.side admin.littleshop.local; + + # SSL configuration + ssl_certificate /etc/letsencrypt/live/admin.dark.side/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.dark.side/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # LAN-only IP whitelist + # Private IPv4 ranges + allow 10.0.0.0/8; # Class A private network + allow 172.16.0.0/12; # Class B private network + allow 192.168.0.0/16; # Class C private network + allow 127.0.0.1; # Localhost + + # Private IPv6 ranges + allow fc00::/7; # Unique local addresses + allow ::1; # IPv6 localhost + + # Add your specific office/VPN IPs here + # allow 203.0.113.50; # Example: Office static IP + # allow 198.51.100.0/24; # Example: VPN subnet + + # Deny all other IPs + deny all; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';" always; + + # Proxy settings + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_buffering off; + + # Admin panel + location /Admin/ { + proxy_pass http://littleshop_backend; + } + + # API endpoints + location /api/ { + proxy_pass http://littleshop_backend; + } + + # Blazor + location /blazor/ { + proxy_pass http://littleshop_backend; + } + + # SignalR hubs + location /activityHub { + proxy_pass http://littleshop_backend; + } + + location /notificationHub { + proxy_pass http://littleshop_backend; + } + + # Static files + location /css/ { + proxy_pass http://littleshop_backend; + } + + location /js/ { + proxy_pass http://littleshop_backend; + } + + location /lib/ { + proxy_pass http://littleshop_backend; + } + + location /uploads/ { + proxy_pass http://littleshop_backend; + } + + # Favicon and manifest + location ~ ^/(favicon\.ico|site\.webmanifest|manifest\.json)$ { + proxy_pass http://littleshop_backend; + } + + # Health check endpoint + location /health { + proxy_pass http://littleshop_backend; + access_log off; + } + + # Root + location / { + proxy_pass http://littleshop_backend; + } + + # Logging + access_log /var/log/nginx/littleshop-admin-access.log; + error_log /var/log/nginx/littleshop-admin-error.log warn; +} +``` + +### Step 4: Enable sites + +```bash +# Create symbolic links +sudo ln -s /etc/nginx/sites-available/littleshop-public-api.conf /etc/nginx/sites-enabled/ +sudo ln -s /etc/nginx/sites-available/littleshop-admin.conf /etc/nginx/sites-enabled/ + +# Test configuration +sudo nginx -t + +# Reload nginx +sudo systemctl reload nginx +``` + +## Testing the Configuration + +### Test Public Endpoints (Should Work) + +```bash +# VAPID key endpoint +curl https://api.littleshop.com/api/push/vapid-key + +# Customer subscription (with valid customerId) +curl -X POST https://api.littleshop.com/api/push/subscribe/customer?customerId= \ + -H "Content-Type: application/json" \ + -d '{"endpoint":"...","keys":{"p256dh":"...","auth":"..."}}' + +# Unsubscribe +curl -X POST https://api.littleshop.com/api/push/unsubscribe \ + -H "Content-Type: application/json" \ + -d '{"endpoint":"..."}' +``` + +### Test Blocked Endpoints (Should Return 403) + +```bash +# Admin API (should be blocked from internet) +curl https://api.littleshop.com/api/push/subscribe + +# Admin panel (should be blocked from internet) +curl https://api.littleshop.com/Admin/Dashboard +``` + +### Test Admin Panel from LAN (Should Work) + +```bash +# From inside LAN +curl https://admin.dark.side/Admin/Dashboard +curl https://admin.dark.side/api/push/subscriptions +``` + +## Docker Compose Integration + +If using Docker Compose with Nginx Proxy Manager: + +```yaml +# docker-compose.yml +version: '3.8' + +services: + littleshop: + image: littleshop:latest + container_name: littleshop + networks: + - littleshop-network + - nginx-proxy-network + environment: + - ASPNETCORE_URLS=http://+:5000 + ports: + - "5000:5000" # Only accessible from docker network + + nginx-proxy-manager: + image: 'jc21/nginx-proxy-manager:latest' + container_name: nginx-proxy-manager + networks: + - nginx-proxy-network + ports: + - '80:80' + - '443:443' + - '81:81' # Admin UI + volumes: + - ./nginx-data:/data + - ./nginx-letsencrypt:/etc/letsencrypt + +networks: + littleshop-network: + driver: bridge + nginx-proxy-network: + driver: bridge +``` + +## Monitoring and Alerting + +### Monitor Failed Access Attempts + +```nginx +# Add to admin server block +location /Admin/ { + access_log /var/log/nginx/admin-access.log combined; + error_log /var/log/nginx/admin-error.log warn; + + # Log denied IPs + if ($remote_addr !~* "^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)") { + access_log /var/log/nginx/admin-denied.log combined; + } + + proxy_pass http://littleshop_backend; +} +``` + +### Set up fail2ban for repeated access attempts + +```ini +# /etc/fail2ban/filter.d/nginx-littleshop-admin.conf +[Definition] +failregex = ^ .* "GET /Admin/ + ^ .* "POST /api/push/(subscribe|test|broadcast) +ignoreregex = +``` + +```ini +# /etc/fail2ban/jail.local +[nginx-littleshop-admin] +enabled = true +port = http,https +filter = nginx-littleshop-admin +logpath = /var/log/nginx/admin-denied.log +maxretry = 5 +bantime = 3600 +findtime = 600 +``` + +## Production Checklist + +- [ ] SSL certificates installed and configured +- [ ] Firewall rules updated to allow 80/443 +- [ ] Rate limiting configured for public endpoints +- [ ] IP whitelist configured for admin panel +- [ ] Monitoring and logging enabled +- [ ] fail2ban configured for intrusion detection +- [ ] Health checks working +- [ ] DNS records pointing to correct servers +- [ ] Backup procedures in place +- [ ] Team has VPN access for admin panel + +## Troubleshooting + +### Issue: "502 Bad Gateway" +**Solution**: Check that LittleShop backend is running on port 5000 +```bash +docker ps | grep littleshop +curl http://localhost:5000/health +``` + +### Issue: "403 Forbidden" from LAN +**Solution**: Check IP whitelist includes your LAN subnet +```bash +# Check your IP +ip addr show +# Or on Windows +ipconfig +``` + +### Issue: Push notifications not working +**Solution**: Verify public endpoints are accessible +```bash +curl -v https://api.littleshop.com/api/push/vapid-key +# Should return 200 OK with public key +``` + +### Issue: CORS errors in browser +**Solution**: Check CORS headers are present in nginx config +```bash +curl -H "Origin: https://example.com" https://api.littleshop.com/api/push/vapid-key -v +# Look for Access-Control-Allow-Origin header +``` diff --git a/IP_STORAGE_ANALYSIS.md b/IP_STORAGE_ANALYSIS.md new file mode 100644 index 0000000..a0899ed --- /dev/null +++ b/IP_STORAGE_ANALYSIS.md @@ -0,0 +1,238 @@ +# IP Address Storage Analysis - Push Subscriptions + +**Date**: November 14, 2025 +**Component**: Push Notification System (`PushSubscription` model) +**Issue**: Security audit flagged IP address storage as potential privacy concern + +--- + +## Executive Summary + +✅ **Conclusion**: IP address storage is **NOT technically required** for Web Push functionality and can be made optional or removed if privacy is a concern. + +--- + +## Technical Analysis + +### Current Implementation + +**Where IP addresses are stored:** +- `PushSubscription.IpAddress` property (nullable string, max 50 chars) +- `Bot.IpAddress` property (required string for bot configurations) + +**How IP addresses are captured:** +```csharp +// In PushNotificationController.cs lines 61, 97 +var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); +``` + +**How IP addresses are used:** +- ✅ Stored in database when subscription is created/updated +- ✅ Displayed in admin UI for monitoring (`/Admin/PushSubscriptions`) +- ❌ **NOT used for deduplication** (uses `Endpoint + UserId` instead) +- ❌ **NOT used for any functional logic** +- ❌ **NOT used for abuse detection** (not currently implemented) + +### Deduplication Logic + +From `PushNotificationService.cs:52-53`: +```csharp +// Duplicate detection uses Endpoint + UserId, NOT IP address +var existingSubscription = await _context.PushSubscriptions + .FirstOrDefaultAsync(ps => ps.Endpoint == subscriptionDto.Endpoint && ps.UserId == userId); +``` + +**Key Finding**: IP address plays NO role in preventing duplicate subscriptions. + +### Web Push API Requirements + +The Web Push API specification (RFC 8030) requires only: +1. **Endpoint URL** - The push service URL +2. **P256DH Key** - Client public key for encryption +3. **Auth Secret** - Authentication secret for encryption + +**IP addresses are NOT part of the Web Push standard.** + +--- + +## Use Cases for IP Storage + +### Current Use Cases (Implemented) +1. **Security Monitoring** - Admin can see geographic origin of subscriptions +2. **Audit Trail** - Track which IP addresses subscribed +3. **Display in Admin UI** - Shows IP badge next to subscriptions + +### Potential Use Cases (NOT Implemented) +1. ❌ Abuse detection (multiple subscriptions from same IP) +2. ❌ Rate limiting by IP +3. ❌ Geographic analytics +4. ❌ Fraud detection + +**Analysis**: Since none of the advanced use cases are implemented, IP storage provides minimal value beyond basic visibility. + +--- + +## Privacy & GDPR Considerations + +### Risks +1. **Personal Data**: IP addresses are considered personal data under GDPR +2. **Data Minimization Principle**: Storing unnecessary personal data violates GDPR +3. **Retention**: No automatic deletion/anonymization of IP addresses +4. **Purpose Limitation**: No clear business purpose for IP storage + +### Compliance Requirements +If IP addresses are kept: +1. ✅ **Privacy Policy**: Must disclose IP collection and purpose +2. ✅ **Consent**: May require explicit consent for IP storage +3. ✅ **Data Subject Rights**: Must honor deletion requests +4. ✅ **Data Retention**: Must define and enforce retention policy +5. ✅ **Legitimate Interest**: Must document legitimate interest for IP storage + +--- + +## Recommendations + +### Option 1: Remove IP Storage (Highest Privacy) ✅ **RECOMMENDED** + +**Pros:** +- Eliminates GDPR concerns +- Simplifies data model +- Reduces storage requirements +- Demonstrates privacy-first approach + +**Cons:** +- Loses ability to monitor subscription origins +- No audit trail for IP addresses + +**Implementation:** +```csharp +// 1. Update PushSubscription model - remove IpAddress property +// 2. Create migration to drop IpAddress column +// 3. Update PushNotificationService - remove ipAddress parameters +// 4. Update PushNotificationController - remove IP capture +// 5. Update admin UI - remove IP display +``` + +### Option 2: Make IP Storage Optional (Configurable) + +**Pros:** +- Flexibility for different deployments +- Can be enabled for high-security environments +- Can be disabled for privacy-focused deployments + +**Cons:** +- Added configuration complexity +- Still requires GDPR compliance when enabled + +**Implementation:** +```json +// appsettings.json +{ + "PushNotifications": { + "StoreIpAddresses": false // Default to privacy-first + } +} +``` + +### Option 3: Hash/Anonymize IP Addresses + +**Pros:** +- Retains some monitoring capability +- Reduces privacy concerns (not personally identifiable) +- Can still detect patterns without storing raw IPs + +**Cons:** +- Still potentially personal data under GDPR +- Adds processing complexity +- Loses geographic information + +**Implementation:** +```csharp +// Store only SHA256 hash of IP for duplicate detection +var ipHash = SHA256.HashData(Encoding.UTF8.GetBytes(ipAddress + salt)); +``` + +--- + +## Impact Assessment + +### If IP Storage is Removed + +| Component | Impact | Mitigation | +|-----------|--------|------------| +| **Push Notification Functionality** | ✅ None | IP not used for functionality | +| **Deduplication** | ✅ None | Uses Endpoint + UserId | +| **Admin UI** | ⚠️ Minor | Remove IP column from table | +| **Security Monitoring** | ⚠️ Moderate | Use User-Agent instead | +| **Audit Logging** | ⚠️ Moderate | Log IPs in application logs (not database) | + +### User-Agent as Alternative + +**User-Agent provides similar monitoring capabilities:** +- Browser identification +- Operating system information +- Device type +- **NOT considered personal data** (no direct user identification) + +**Current Implementation**: User-Agent is already captured and stored. + +--- + +## Decision Matrix + +| Criteria | Remove IP | Make Optional | Hash IP | +|----------|-----------|---------------|---------| +| **Privacy Score** | 10/10 | 7/10 | 8/10 | +| **GDPR Compliance** | 10/10 | 5/10 | 6/10 | +| **Implementation Effort** | Low | Medium | Medium | +| **Monitoring Capability** | 0/10 | 10/10 | 5/10 | +| **Maintenance Burden** | 0/10 | 3/10 | 5/10 | + +--- + +## Recommended Implementation Plan + +### Phase 1: Documentation (Immediate) ✅ **COMPLETE** +- ✅ Document IP storage purpose and GDPR considerations +- ✅ Update admin UI with privacy notice +- ✅ Add comment to model explaining IP usage + +### Phase 2: Configuration (1-2 hours) +- Add `appsettings` option to enable/disable IP storage +- Update service to check configuration before storing IP +- Default to `StoreIpAddresses: false` for new deployments + +### Phase 3: Removal (Optional, 2-3 hours) +- Create migration to drop `IpAddress` column from `PushSubscriptions` table +- Remove IP capture from controllers +- Update admin UI to remove IP column +- Update all related tests + +--- + +## Conclusion + +**IP address storage for push subscriptions is NOT technically necessary** and serves only monitoring purposes. Given GDPR concerns and the lack of implemented use cases for IP addresses, the recommended approach is: + +1. **Short Term**: Document current usage and add configuration option +2. **Long Term**: Remove IP storage entirely to maximize privacy and GDPR compliance + +The system will function identically without IP storage, and User-Agent data provides sufficient monitoring capability for most use cases. + +--- + +## Related Files + +- `LittleShop/Models/PushSubscription.cs:30` - Model definition +- `LittleShop/Services/PushNotificationService.cs:39,97` - Service methods +- `LittleShop/Controllers/PushNotificationController.cs:61,97` - IP capture +- `LittleShop/Areas/Admin/Views/PushSubscriptions/Index.cshtml:138-140` - IP display +- `SECURITY_FIXES_PROGRESS_2025-11-14.md` - Security audit tracking + +--- + +## References + +- [RFC 8030 - Generic Event Delivery Using HTTP Push](https://datatracker.ietf.org/doc/html/rfc8030) +- [GDPR Article 5 - Principles relating to processing of personal data](https://gdpr-info.eu/art-5-gdpr/) +- [ICO Guidance - What is personal data?](https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/personal-information-what-is-it/) diff --git a/LittleShop/Areas/Admin/Controllers/AccountController.cs b/LittleShop/Areas/Admin/Controllers/AccountController.cs index d8aeff6..85145ce 100644 --- a/LittleShop/Areas/Admin/Controllers/AccountController.cs +++ b/LittleShop/Areas/Admin/Controllers/AccountController.cs @@ -28,14 +28,12 @@ public class AccountController : Controller } [HttpPost] - // [ValidateAntiForgeryToken] // Temporarily disabled for HTTPS proxy issue + [ValidateAntiForgeryToken] public async Task Login(string Username, string Password) { // Make parameters case-insensitive for form compatibility var username = Username?.ToLowerInvariant(); var password = Password; - - Console.WriteLine($"Received Username: '{username}', Password: '{password}'"); if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) { diff --git a/LittleShop/Areas/Admin/Controllers/BotsController.cs b/LittleShop/Areas/Admin/Controllers/BotsController.cs index dd8104f..37daced 100644 --- a/LittleShop/Areas/Admin/Controllers/BotsController.cs +++ b/LittleShop/Areas/Admin/Controllers/BotsController.cs @@ -74,16 +74,6 @@ public class BotsController : Controller [ValidateAntiForgeryToken] public async Task Wizard(BotWizardDto dto) { - Console.WriteLine("=== BOT WIZARD DEBUG ==="); - Console.WriteLine($"Received: BotName='{dto?.BotName}', BotUsername='{dto?.BotUsername}', PersonalityName='{dto?.PersonalityName}'"); - Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}"); - Console.WriteLine("Raw form data:"); - foreach (var key in Request.Form.Keys) - { - Console.WriteLine($" {key} = '{Request.Form[key]}'"); - } - Console.WriteLine("========================"); - _logger.LogInformation("Wizard POST received - BotName: '{BotName}', BotUsername: '{BotUsername}'", dto.BotName, dto.BotUsername); if (!ModelState.IsValid) @@ -102,7 +92,6 @@ public class BotsController : Controller var personalities = new[] { "Alan", "Dave", "Sarah", "Mike", "Emma", "Tom" }; var random = new Random(); dto.PersonalityName = personalities[random.Next(personalities.Length)]; - Console.WriteLine($"Auto-assigned personality: {dto.PersonalityName}"); } // Generate BotFather commands diff --git a/LittleShop/Areas/Admin/Controllers/CategoriesController.cs b/LittleShop/Areas/Admin/Controllers/CategoriesController.cs index b7cb72a..254c08a 100644 --- a/LittleShop/Areas/Admin/Controllers/CategoriesController.cs +++ b/LittleShop/Areas/Admin/Controllers/CategoriesController.cs @@ -31,15 +31,8 @@ public class CategoriesController : Controller [ValidateAntiForgeryToken] public async Task Create(CreateCategoryDto model) { - Console.WriteLine($"Received Category: Name='{model?.Name}', Description='{model?.Description}'"); - Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}"); - if (!ModelState.IsValid) { - foreach (var error in ModelState) - { - Console.WriteLine($"ModelState Error - Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}"); - } return View(model); } @@ -70,18 +63,8 @@ public class CategoriesController : Controller [ValidateAntiForgeryToken] public async Task Edit(Guid id, UpdateCategoryDto model) { - Console.WriteLine($"Edit POST - CategoryId: {id}"); - Console.WriteLine($"Edit POST - Name: '{model?.Name}'"); - Console.WriteLine($"Edit POST - Description: '{model?.Description}'"); - Console.WriteLine($"Edit POST - IsActive: {model?.IsActive} (HasValue: {model?.IsActive.HasValue})"); - Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}"); - if (!ModelState.IsValid) { - foreach (var error in ModelState) - { - Console.WriteLine($"ModelState Error - Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}"); - } ViewData["CategoryId"] = id; return View(model); } diff --git a/LittleShop/Areas/Admin/Controllers/CustomersController.cs b/LittleShop/Areas/Admin/Controllers/CustomersController.cs new file mode 100644 index 0000000..5849f4c --- /dev/null +++ b/LittleShop/Areas/Admin/Controllers/CustomersController.cs @@ -0,0 +1,339 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using LittleShop.Services; +using LittleShop.DTOs; + +namespace LittleShop.Areas.Admin.Controllers; + +[Area("Admin")] +[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")] +public class CustomersController : Controller +{ + private readonly ICustomerService _customerService; + private readonly IOrderService _orderService; + private readonly ILogger _logger; + + public CustomersController( + ICustomerService customerService, + IOrderService orderService, + ILogger logger) + { + _customerService = customerService; + _orderService = orderService; + _logger = logger; + } + + public async Task Index(string searchTerm = "") + { + try + { + IEnumerable customers; + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + customers = await _customerService.SearchCustomersAsync(searchTerm); + ViewData["SearchTerm"] = searchTerm; + } + else + { + customers = await _customerService.GetAllCustomersAsync(); + } + + return View(customers.OrderByDescending(c => c.CreatedAt)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading customers index"); + TempData["ErrorMessage"] = "Error loading customers"; + return View(new List()); + } + } + + public async Task Details(Guid id) + { + try + { + var customer = await _customerService.GetCustomerByIdAsync(id); + if (customer == null) + { + TempData["ErrorMessage"] = "Customer not found"; + return RedirectToAction(nameof(Index)); + } + + // Get customer's order history + var allOrders = await _orderService.GetAllOrdersAsync(); + var customerOrders = allOrders + .Where(o => o.CustomerId == id) + .OrderByDescending(o => o.CreatedAt) + .ToList(); + + ViewData["CustomerOrders"] = customerOrders; + + return View(customer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading customer {CustomerId}", id); + TempData["ErrorMessage"] = "Error loading customer details"; + return RedirectToAction(nameof(Index)); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Block(Guid id, string reason) + { + try + { + if (string.IsNullOrWhiteSpace(reason)) + { + TempData["ErrorMessage"] = "Block reason is required"; + return RedirectToAction(nameof(Details), new { id }); + } + + var success = await _customerService.BlockCustomerAsync(id, reason); + if (success) + { + _logger.LogWarning("Customer {CustomerId} blocked by admin. Reason: {Reason}", id, reason); + TempData["SuccessMessage"] = "Customer blocked successfully"; + } + else + { + TempData["ErrorMessage"] = "Failed to block customer"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error blocking customer {CustomerId}", id); + TempData["ErrorMessage"] = "Error blocking customer"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Unblock(Guid id) + { + try + { + var success = await _customerService.UnblockCustomerAsync(id); + if (success) + { + _logger.LogInformation("Customer {CustomerId} unblocked by admin", id); + TempData["SuccessMessage"] = "Customer unblocked successfully"; + } + else + { + TempData["ErrorMessage"] = "Failed to unblock customer"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unblocking customer {CustomerId}", id); + TempData["ErrorMessage"] = "Error unblocking customer"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RefreshRiskScore(Guid id) + { + try + { + await _customerService.UpdateCustomerMetricsAsync(id); + TempData["SuccessMessage"] = "Risk score recalculated successfully"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing risk score for customer {CustomerId}", id); + TempData["ErrorMessage"] = "Error refreshing risk score"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(Guid id) + { + try + { + var success = await _customerService.DeleteCustomerAsync(id); + if (success) + { + _logger.LogWarning("Customer {CustomerId} deleted by admin", id); + TempData["SuccessMessage"] = "Customer deleted successfully (soft delete - data retained)"; + return RedirectToAction(nameof(Index)); + } + else + { + TempData["ErrorMessage"] = "Failed to delete customer"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting customer {CustomerId}", id); + TempData["ErrorMessage"] = "Error deleting customer"; + } + + return RedirectToAction(nameof(Details), new { id }); + } + + /// + /// Export customer data as JSON (GDPR "Right to Data Portability") + /// + [HttpGet] + public async Task ExportJson(Guid id) + { + try + { + var exportData = await _customerService.GetCustomerDataForExportAsync(id); + if (exportData == null) + { + return NotFound(); + } + + var fileName = $"customer-data-{exportData.TelegramUsername ?? exportData.TelegramUserId.ToString()}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json"; + var jsonOptions = new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase + }; + var jsonData = System.Text.Json.JsonSerializer.Serialize(exportData, jsonOptions); + + _logger.LogInformation("Customer {CustomerId} data exported as JSON by admin", id); + return File(System.Text.Encoding.UTF8.GetBytes(jsonData), "application/json", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting customer {CustomerId} data as JSON", id); + TempData["ErrorMessage"] = "Error generating JSON export"; + return RedirectToAction(nameof(Details), new { id }); + } + } + + /// + /// Export customer data as CSV (GDPR "Right to Data Portability") + /// + [HttpGet] + public async Task ExportCsv(Guid id) + { + try + { + var exportData = await _customerService.GetCustomerDataForExportAsync(id); + if (exportData == null) + { + return NotFound(); + } + + var fileName = $"customer-data-{exportData.TelegramUsername ?? exportData.TelegramUserId.ToString()}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"; + var csvData = GenerateCsvExport(exportData); + + _logger.LogInformation("Customer {CustomerId} data exported as CSV by admin", id); + return File(System.Text.Encoding.UTF8.GetBytes(csvData), "text/csv", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error exporting customer {CustomerId} data as CSV", id); + TempData["ErrorMessage"] = "Error generating CSV export"; + return RedirectToAction(nameof(Details), new { id }); + } + } + + /// + /// Helper method to generate CSV export from customer data + /// + private string GenerateCsvExport(LittleShop.DTOs.CustomerDataExportDto exportData) + { + var csv = new System.Text.StringBuilder(); + + // Customer Profile Section + csv.AppendLine("CUSTOMER PROFILE"); + csv.AppendLine("Field,Value"); + csv.AppendLine($"Customer ID,{exportData.CustomerId}"); + csv.AppendLine($"Telegram User ID,{exportData.TelegramUserId}"); + csv.AppendLine($"Telegram Username,\"{EscapeCsv(exportData.TelegramUsername)}\""); + csv.AppendLine($"Telegram Display Name,\"{EscapeCsv(exportData.TelegramDisplayName)}\""); + csv.AppendLine($"First Name,\"{EscapeCsv(exportData.TelegramFirstName)}\""); + csv.AppendLine($"Last Name,\"{EscapeCsv(exportData.TelegramLastName)}\""); + csv.AppendLine($"Email,\"{EscapeCsv(exportData.Email)}\""); + csv.AppendLine($"Phone,\"{EscapeCsv(exportData.PhoneNumber)}\""); + csv.AppendLine($"Allow Marketing,{exportData.AllowMarketing}"); + csv.AppendLine($"Allow Order Updates,{exportData.AllowOrderUpdates}"); + csv.AppendLine($"Language,{exportData.Language}"); + csv.AppendLine($"Timezone,{exportData.Timezone}"); + csv.AppendLine($"Total Orders,{exportData.TotalOrders}"); + csv.AppendLine($"Total Spent,{exportData.TotalSpent:F2}"); + csv.AppendLine($"Average Order Value,{exportData.AverageOrderValue:F2}"); + csv.AppendLine($"Is Blocked,{exportData.IsBlocked}"); + csv.AppendLine($"Block Reason,\"{EscapeCsv(exportData.BlockReason)}\""); + csv.AppendLine($"Risk Score,{exportData.RiskScore}"); + csv.AppendLine($"Created At,{exportData.CreatedAt:yyyy-MM-dd HH:mm:ss}"); + csv.AppendLine($"Updated At,{exportData.UpdatedAt:yyyy-MM-dd HH:mm:ss}"); + csv.AppendLine(); + + // Orders Section + csv.AppendLine("ORDERS"); + csv.AppendLine("Order ID,Status,Total Amount,Currency,Order Date,Shipping Name,Shipping Address,City,Post Code,Country,Tracking Number"); + foreach (var order in exportData.Orders) + { + csv.AppendLine($"{order.OrderId},{order.Status},{order.TotalAmount:F2},{order.Currency},{order.OrderDate:yyyy-MM-dd HH:mm:ss}," + + $"\"{EscapeCsv(order.ShippingName)}\",\"{EscapeCsv(order.ShippingAddress)}\",\"{EscapeCsv(order.ShippingCity)}\"," + + $"\"{order.ShippingPostCode}\",\"{order.ShippingCountry}\",\"{EscapeCsv(order.TrackingNumber)}\""); + + // Order Items sub-section + if (order.Items.Any()) + { + csv.AppendLine(" Order Items:"); + csv.AppendLine(" Product Name,Variant,Quantity,Unit Price,Total Price"); + foreach (var item in order.Items) + { + csv.AppendLine($" \"{EscapeCsv(item.ProductName)}\",\"{EscapeCsv(item.VariantName)}\"," + + $"{item.Quantity},{item.UnitPrice:F2},{item.TotalPrice:F2}"); + } + } + } + csv.AppendLine(); + + // Messages Section + if (exportData.Messages.Any()) + { + csv.AppendLine("MESSAGES"); + csv.AppendLine("Sent At,Type,Content,Was Read,Read At"); + foreach (var message in exportData.Messages) + { + csv.AppendLine($"{message.SentAt:yyyy-MM-dd HH:mm:ss},{message.MessageType}," + + $"\"{EscapeCsv(message.Content)}\",{message.WasRead}," + + $"{(message.ReadAt.HasValue ? message.ReadAt.Value.ToString("yyyy-MM-dd HH:mm:ss") : "")}"); + } + csv.AppendLine(); + } + + // Reviews Section + if (exportData.Reviews.Any()) + { + csv.AppendLine("REVIEWS"); + csv.AppendLine("Product Name,Rating,Comment,Created At,Is Approved,Is Verified Purchase"); + foreach (var review in exportData.Reviews) + { + csv.AppendLine($"\"{EscapeCsv(review.ProductName)}\",{review.Rating}," + + $"\"{EscapeCsv(review.Comment)}\",{review.CreatedAt:yyyy-MM-dd HH:mm:ss}," + + $"{review.IsApproved},{review.IsVerifiedPurchase}"); + } + } + + return csv.ToString(); + } + + /// + /// Escape CSV values containing quotes, commas, or newlines + /// + private string EscapeCsv(string? value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + return value.Replace("\"", "\"\""); // Escape quotes by doubling them + } +} diff --git a/LittleShop/Areas/Admin/Controllers/OrdersController.cs b/LittleShop/Areas/Admin/Controllers/OrdersController.cs index 6647041..43bae4f 100644 --- a/LittleShop/Areas/Admin/Controllers/OrdersController.cs +++ b/LittleShop/Areas/Admin/Controllers/OrdersController.cs @@ -56,12 +56,13 @@ public class OrdersController : Controller break; } - // Get workflow counts for tab badges - ViewData["PendingCount"] = (await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.PendingPayment)).Count(); - ViewData["AcceptCount"] = (await _orderService.GetOrdersRequiringActionAsync()).Count(); - ViewData["PackingCount"] = (await _orderService.GetOrdersForPackingAsync()).Count(); - ViewData["DispatchedCount"] = (await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.Dispatched)).Count(); - ViewData["OnHoldCount"] = (await _orderService.GetOrdersOnHoldAsync()).Count(); + // Get workflow counts for tab badges (single optimized query) + var statusCounts = await _orderService.GetOrderStatusCountsAsync(); + ViewData["PendingCount"] = statusCounts.PendingPaymentCount; + ViewData["AcceptCount"] = statusCounts.RequiringActionCount; + ViewData["PackingCount"] = statusCounts.ForPackingCount; + ViewData["DispatchedCount"] = statusCounts.DispatchedCount; + ViewData["OnHoldCount"] = statusCounts.OnHoldCount; return View(); } @@ -137,15 +138,6 @@ public class OrdersController : Controller { if (!ModelState.IsValid) { - // Log validation errors for debugging - foreach (var error in ModelState) - { - if (error.Value?.Errors.Count > 0) - { - Console.WriteLine($"Validation error for {error.Key}: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}"); - } - } - // Return to details page with error var order = await _orderService.GetOrderByIdAsync(id); if (order == null) diff --git a/LittleShop/Areas/Admin/Controllers/PaymentsController.cs b/LittleShop/Areas/Admin/Controllers/PaymentsController.cs new file mode 100644 index 0000000..ade57e1 --- /dev/null +++ b/LittleShop/Areas/Admin/Controllers/PaymentsController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using LittleShop.Services; +using LittleShop.Enums; + +namespace LittleShop.Areas.Admin.Controllers; + +[Area("Admin")] +[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")] +public class PaymentsController : Controller +{ + private readonly ICryptoPaymentService _paymentService; + private readonly IOrderService _orderService; + private readonly ILogger _logger; + + public PaymentsController( + ICryptoPaymentService paymentService, + IOrderService orderService, + ILogger _logger) + { + _paymentService = paymentService; + _orderService = orderService; + this._logger = _logger; + } + + //GET: Admin/Payments + public async Task Index(string? status = null) + { + try + { + var payments = await _paymentService.GetAllPaymentsAsync(); + + // Filter by status if provided + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, out var paymentStatus)) + { + payments = payments.Where(p => p.Status == paymentStatus); + ViewData["CurrentStatus"] = status; + } + + // Get orders for payment-order linking + var allOrders = await _orderService.GetAllOrdersAsync(); + ViewData["Orders"] = allOrders.ToDictionary(o => o.Id, o => o); + + return View(payments.OrderByDescending(p => p.CreatedAt)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving payments list"); + TempData["ErrorMessage"] = "Failed to load payments. Please try again."; + return View(Enumerable.Empty()); + } + } +} diff --git a/LittleShop/Areas/Admin/Controllers/ProductsController.cs b/LittleShop/Areas/Admin/Controllers/ProductsController.cs index 7af0e61..3e8b05a 100644 --- a/LittleShop/Areas/Admin/Controllers/ProductsController.cs +++ b/LittleShop/Areas/Admin/Controllers/ProductsController.cs @@ -14,13 +14,17 @@ public class ProductsController : Controller private readonly ICategoryService _categoryService; private readonly IProductImportService _importService; private readonly IVariantCollectionService _variantCollectionService; + private readonly IReviewService _reviewService; + private readonly ILogger _logger; - public ProductsController(IProductService productService, ICategoryService categoryService, IProductImportService importService, IVariantCollectionService variantCollectionService) + public ProductsController(IProductService productService, ICategoryService categoryService, IProductImportService importService, IVariantCollectionService variantCollectionService, IReviewService reviewService, ILogger logger) { _productService = productService; _categoryService = categoryService; _importService = importService; _variantCollectionService = variantCollectionService; + _reviewService = reviewService; + _logger = logger; } public async Task Index() @@ -49,24 +53,11 @@ public class ProductsController : Controller [ValidateAntiForgeryToken] public async Task Create(CreateProductDto model) { - Console.WriteLine($"Received Product: Name='{model?.Name}', Description='{model?.Description}', Price={model?.Price}, Stock={model?.StockQuantity}"); - Console.WriteLine($"CategoryId: {model?.CategoryId}"); - Console.WriteLine($"Weight: {model?.Weight}, WeightUnit: {model?.WeightUnit}"); - Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}"); - // Remove Description validation errors since it's optional ModelState.Remove("Description"); if (!ModelState.IsValid) { - Console.WriteLine("Validation errors:"); - foreach (var error in ModelState) - { - if (error.Value?.Errors.Count > 0) - { - Console.WriteLine($" {error.Key}: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}"); - } - } var categories = await _categoryService.GetAllCategoriesAsync(); ViewData["Categories"] = categories.Where(c => c.IsActive); @@ -114,32 +105,9 @@ public class ProductsController : Controller product.VariantsJson = System.Text.Json.JsonSerializer.Serialize(variantsForJson); } - // TODO: Add ReviewService injection and retrieve actual reviews - // For now, providing mock review data for demonstration - ViewData["ProductReviews"] = new[] - { - new { - Rating = 5, - CustomerName = "John D.", - Comment = "Excellent quality! Exceeded my expectations.", - CreatedAt = DateTime.Now.AddDays(-7), - OrderReference = "ORD-123456" - }, - new { - Rating = 4, - CustomerName = "Sarah M.", - Comment = "Very good product, fast delivery.", - CreatedAt = DateTime.Now.AddDays(-14), - OrderReference = "ORD-789012" - }, - new { - Rating = 5, - CustomerName = (string?)null, // Anonymous - Comment = "Love it! Will order again.", - CreatedAt = DateTime.Now.AddDays(-21), - OrderReference = "ORD-345678" - } - }; + // Load actual product reviews from ReviewService + var productReviews = await _reviewService.GetReviewsByProductAsync(id, approvedOnly: true); + ViewData["ProductReviews"] = productReviews; var model = new UpdateProductDto { @@ -325,16 +293,29 @@ public class ProductsController : Controller [HttpPost] [ValidateAntiForgeryToken] - public async Task DeleteAllSalesData() + public async Task DeleteAllSalesData(string confirmText) { + // Require explicit typed confirmation + if (confirmText != "DELETE ALL SALES DATA") + { + TempData["ErrorMessage"] = "❌ Confirmation text did not match. Operation cancelled for safety."; + return RedirectToAction(nameof(Index)); + } + try { var deletedCount = await _importService.DeleteAllOrdersAndSalesDataAsync(); + + // Log this critical action + _logger.LogWarning("CRITICAL: User {UserId} deleted ALL sales data ({Count} records)", + User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, deletedCount); + TempData["SuccessMessage"] = $"✅ Successfully deleted {deletedCount} sales records (orders, payments, customers, messages)"; return RedirectToAction(nameof(Index)); } catch (Exception ex) { + _logger.LogError(ex, "Failed to delete all sales data"); TempData["ErrorMessage"] = $"Failed to delete sales data: {ex.Message}"; return RedirectToAction(nameof(Index)); } diff --git a/LittleShop/Areas/Admin/Controllers/PushSubscriptionsController.cs b/LittleShop/Areas/Admin/Controllers/PushSubscriptionsController.cs new file mode 100644 index 0000000..00b5de2 --- /dev/null +++ b/LittleShop/Areas/Admin/Controllers/PushSubscriptionsController.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using LittleShop.Services; + +namespace LittleShop.Areas.Admin.Controllers; + +[Area("Admin")] +[Authorize(AuthenticationSchemes = "Cookies", Roles = "Admin")] +public class PushSubscriptionsController : Controller +{ + private readonly IPushNotificationService _pushService; + private readonly ILogger _logger; + + public PushSubscriptionsController( + IPushNotificationService pushService, + ILogger logger) + { + _pushService = pushService; + _logger = logger; + } + + // GET: Admin/PushSubscriptions + public async Task Index() + { + try + { + var subscriptions = await _pushService.GetActiveSubscriptionsAsync(); + return View(subscriptions.OrderByDescending(s => s.SubscribedAt)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving push subscriptions list"); + TempData["ErrorMessage"] = "Failed to load push subscriptions. Please try again."; + return View(new List()); + } + } + + // POST: Admin/PushSubscriptions/Delete/{id} + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(int id) + { + try + { + // Find subscription by ID and delete via UnsubscribeAsync (which uses endpoint) + var subscriptions = await _pushService.GetActiveSubscriptionsAsync(); + var subscription = subscriptions.FirstOrDefault(s => s.Id == id); + + if (subscription == null) + { + TempData["ErrorMessage"] = "Push subscription not found."; + return RedirectToAction(nameof(Index)); + } + + var success = await _pushService.UnsubscribeAsync(subscription.Endpoint); + + if (success) + { + _logger.LogInformation("Deleted push subscription {Id} (Endpoint: {Endpoint})", id, subscription.Endpoint); + TempData["SuccessMessage"] = "Push subscription deleted successfully."; + } + else + { + _logger.LogWarning("Failed to delete push subscription {Id}", id); + TempData["ErrorMessage"] = "Failed to delete push subscription."; + } + + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting push subscription {Id}", id); + TempData["ErrorMessage"] = $"Error deleting push subscription: {ex.Message}"; + return RedirectToAction(nameof(Index)); + } + } + + // POST: Admin/PushSubscriptions/CleanupExpired + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CleanupExpired() + { + try + { + var deletedCount = await _pushService.CleanupExpiredSubscriptionsAsync(); + + _logger.LogInformation("Cleaned up {Count} expired push subscriptions", deletedCount); + + if (deletedCount > 0) + { + TempData["SuccessMessage"] = $"Successfully cleaned up {deletedCount} expired subscription(s)."; + } + else + { + TempData["InfoMessage"] = "No expired subscriptions found to clean up."; + } + + return RedirectToAction(nameof(Index)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning up expired push subscriptions"); + TempData["ErrorMessage"] = $"Error during cleanup: {ex.Message}"; + return RedirectToAction(nameof(Index)); + } + } +} diff --git a/LittleShop/Areas/Admin/Controllers/ReviewsController.cs b/LittleShop/Areas/Admin/Controllers/ReviewsController.cs index 883ef2b..3cd31ea 100644 --- a/LittleShop/Areas/Admin/Controllers/ReviewsController.cs +++ b/LittleShop/Areas/Admin/Controllers/ReviewsController.cs @@ -55,6 +55,7 @@ public class ReviewsController : Controller } [HttpPost] + [ValidateAntiForgeryToken] public async Task Approve(Guid id) { try @@ -86,6 +87,7 @@ public class ReviewsController : Controller } [HttpPost] + [ValidateAntiForgeryToken] public async Task Delete(Guid id) { try diff --git a/LittleShop/Areas/Admin/Controllers/UsersController.cs b/LittleShop/Areas/Admin/Controllers/UsersController.cs index d456b6f..8c8cc60 100644 --- a/LittleShop/Areas/Admin/Controllers/UsersController.cs +++ b/LittleShop/Areas/Admin/Controllers/UsersController.cs @@ -86,9 +86,9 @@ public class UsersController : Controller } // Validate password if provided - if (!string.IsNullOrEmpty(model.Password) && model.Password.Length < 3) + if (!string.IsNullOrEmpty(model.Password) && model.Password.Length < 8) { - ModelState.AddModelError("Password", "Password must be at least 3 characters if changing"); + ModelState.AddModelError("Password", "Password must be at least 8 characters if changing"); } if (!ModelState.IsValid) diff --git a/LittleShop/Areas/Admin/Views/Customers/Details.cshtml b/LittleShop/Areas/Admin/Views/Customers/Details.cshtml new file mode 100644 index 0000000..740e866 --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Customers/Details.cshtml @@ -0,0 +1,445 @@ +@model LittleShop.DTOs.CustomerDto +@{ + ViewData["Title"] = $"Customer: {Model.TelegramDisplayName}"; + var customerOrders = ViewData["CustomerOrders"] as List ?? new List(); +} + +
+
+ +
+
+ + +
+
+

+ @Model.TelegramDisplayName + @if (Model.IsBlocked) + { + + BLOCKED + + } + else if (!Model.IsActive) + { + + DELETED + + } + else + { + + ACTIVE + + } +

+

+ Telegram: @@@Model.TelegramUsername | ID: @Model.TelegramUserId +

+
+ +
+ + +
+ +
+
+
+
Customer Information
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Display Name:@Model.TelegramDisplayName
Full Name: + @if (!string.IsNullOrEmpty(Model.TelegramFirstName) || !string.IsNullOrEmpty(Model.TelegramLastName)) + { + @Model.TelegramFirstName @Model.TelegramLastName + } + else + { + Not provided + } +
Telegram Username:@@@Model.TelegramUsername
Telegram User ID:@Model.TelegramUserId
Language:@Model.Language.ToUpper()
Timezone:@Model.Timezone
Customer Since:@Model.CreatedAt.ToString("MMMM dd, yyyy")
Last Active: + @if (Model.LastActiveAt > DateTime.MinValue) + { + var daysAgo = (DateTime.UtcNow - Model.LastActiveAt).Days; + @Model.LastActiveAt.ToString("MMMM dd, yyyy HH:mm") + @if (daysAgo <= 1) + { + Active today + } + else if (daysAgo <= 7) + { + @daysAgo days ago + } + else if (daysAgo <= 30) + { + @daysAgo days ago + } + else + { + @daysAgo days ago + } + } + else + { + Never + } +
+
+
+
+ + +
+
+
+
Risk Assessment
+
+
+ @{ + var riskLevel = Model.RiskScore >= 80 ? "Very High Risk" : + Model.RiskScore >= 50 ? "High Risk" : + Model.RiskScore >= 30 ? "Medium Risk" : + "Low Risk"; + var riskClass = Model.RiskScore >= 80 ? "danger" : + Model.RiskScore >= 50 ? "warning" : + Model.RiskScore >= 30 ? "info" : + "success"; + } +
+

+ @Model.RiskScore +

+

@riskLevel

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
Total Orders:@Model.TotalOrders
Successful Orders:@Model.SuccessfulOrders
Cancelled Orders:@Model.CancelledOrders
Disputed Orders:@Model.DisputedOrders
Success Rate: + @if (Model.TotalOrders > 0) + { + var successRate = (Model.SuccessfulOrders * 100.0) / Model.TotalOrders; + @successRate.ToString("F1")% + } + else + { + N/A + } +
+ +
+
+ @Html.AntiForgeryToken() + +
+
+
+
+
+
+ + +
+
+
+
+
Total Spent
+

£@Model.TotalSpent.ToString("N2")

+
+
+
+
+
+
+
Average Order Value
+

£@Model.AverageOrderValue.ToString("N2")

+
+
+
+
+
+
+
First Order
+

+ @if (Model.FirstOrderDate > DateTime.MinValue) + { + @Model.FirstOrderDate.ToString("MMM dd, yyyy") + } + else + { + No orders + } +

+
+
+
+
+
+
+
Last Order
+

+ @if (Model.LastOrderDate > DateTime.MinValue) + { + @Model.LastOrderDate.ToString("MMM dd, yyyy") + } + else + { + No orders + } +

+
+
+
+
+ + +
+
+
+
+
Customer Management Actions
+
+
+ @if (Model.IsBlocked) + { +
+
Customer is Blocked
+

Reason: @Model.BlockReason

+
+ +
+ @Html.AntiForgeryToken() + +
+ } + else + { + + } + + + + @if (!string.IsNullOrEmpty(Model.CustomerNotes)) + { +
+
Admin Notes:
+

@Model.CustomerNotes

+ } +
+
+
+
+ + +
+
+
+
+
+ Order History + @customerOrders.Count +
+
+
+ @if (customerOrders.Any()) + { +
+ + + + + + + + + + + + + @foreach (var order in customerOrders) + { + + + + + + + + + } + +
Order IDDateStatusItemsTotalActions
@order.Id.ToString().Substring(0, 8)@order.CreatedAt.ToString("MMM dd, yyyy HH:mm") + @{ + var statusClass = order.Status.ToString() == "Delivered" ? "success" : + order.Status.ToString() == "Cancelled" ? "danger" : + order.Status.ToString() == "PendingPayment" ? "warning" : + "info"; + } + @order.Status + @order.Items.Sum(i => i.Quantity)£@order.TotalAmount.ToString("N2") + + View + +
+
+ } + else + { +
+ No orders yet for this customer. +
+ } +
+
+
+
+ + + + + + diff --git a/LittleShop/Areas/Admin/Views/Customers/Index.cshtml b/LittleShop/Areas/Admin/Views/Customers/Index.cshtml new file mode 100644 index 0000000..63ddaf6 --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Customers/Index.cshtml @@ -0,0 +1,232 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Customers"; + var searchTerm = ViewData["SearchTerm"] as string ?? ""; +} + +
+
+

Customer Management

+

Manage customer accounts, view order history, and monitor risk scores

+
+
+ + +
+
+
+ + + @if (!string.IsNullOrEmpty(searchTerm)) + { + + Clear + + } +
+
+
+ + @Model.Count() customer@(Model.Count() != 1 ? "s" : "") found + +
+
+ + +
+
+
+
+
Total Customers
+

@Model.Count()

+
+
+
+
+
+
+
Active
+

@Model.Count(c => c.IsActive && !c.IsBlocked)

+
+
+
+
+
+
+
Blocked
+

@Model.Count(c => c.IsBlocked)

+
+
+
+
+
+
+
High Risk (>70)
+

@Model.Count(c => c.RiskScore > 70)

+
+
+
+
+ + +@if (Model.Any()) +{ +
+
+
Customer List
+
+
+
+ + + + + + + + + + + + + + + @foreach (var customer in Model) + { + + + + + + + + + + + } + +
CustomerTelegramStatusOrdersTotal SpentRisk ScoreLast ActiveActions
+
+ @customer.TelegramDisplayName + @if (!string.IsNullOrEmpty(customer.TelegramFirstName) && !string.IsNullOrEmpty(customer.TelegramLastName)) + { +
@customer.TelegramFirstName @customer.TelegramLastName + } +
+
+
+ @@@customer.TelegramUsername +
ID: @customer.TelegramUserId +
+
+ @if (customer.IsBlocked) + { + + Blocked + + } + else if (!customer.IsActive) + { + + Deleted + + } + else + { + + Active + + } + +
+ @customer.TotalOrders + @if (customer.SuccessfulOrders > 0) + { +
@customer.SuccessfulOrders successful + } + @if (customer.CancelledOrders > 0) + { +
@customer.CancelledOrders cancelled + } + @if (customer.DisputedOrders > 0) + { +
@customer.DisputedOrders disputed + } +
+
+ £@customer.TotalSpent.ToString("N2") + @if (customer.AverageOrderValue > 0) + { +
Avg: £@customer.AverageOrderValue.ToString("N2") + } +
+ @{ + var riskBadgeClass = customer.RiskScore >= 80 ? "bg-danger" : + customer.RiskScore >= 50 ? "bg-warning" : + customer.RiskScore >= 30 ? "bg-info" : + "bg-success"; + var riskIcon = customer.RiskScore >= 80 ? "fa-exclamation-triangle" : + customer.RiskScore >= 50 ? "fa-exclamation-circle" : + customer.RiskScore >= 30 ? "fa-info-circle" : + "fa-check-circle"; + } + + @customer.RiskScore + + + @if (customer.LastActiveAt > DateTime.MinValue) + { + var daysAgo = (DateTime.UtcNow - customer.LastActiveAt).Days; + @customer.LastActiveAt.ToString("MMM dd, yyyy") + @if (daysAgo <= 1) + { +
Today + } + else if (daysAgo <= 7) + { +
@daysAgo days ago + } + else if (daysAgo <= 30) + { +
@daysAgo days ago + } + else + { +
@daysAgo days ago + } + } + else + { + Never + } +
+ + + +
+
+
+
+} +else +{ +
+ + @if (!string.IsNullOrEmpty(searchTerm)) + { + No customers found matching "@searchTerm" +

Try a different search term or view all customers.

+ } + else + { + No customers yet +

Customers will appear here automatically when they place their first order through the TeleBot.

+ } +
+} diff --git a/LittleShop/Areas/Admin/Views/Dashboard/Index.cshtml b/LittleShop/Areas/Admin/Views/Dashboard/Index.cshtml index a28209e..58cb064 100644 --- a/LittleShop/Areas/Admin/Views/Dashboard/Index.cshtml +++ b/LittleShop/Areas/Admin/Views/Dashboard/Index.cshtml @@ -2,12 +2,107 @@ ViewData["Title"] = "Dashboard"; } -
+

Dashboard

+

Welcome back! Here's what needs your attention today.

+ + + +@{ + var pendingOrders = (int)ViewData["PendingOrders"]!; + var lowStockProducts = (int)ViewData["LowStockProducts"]!; + var outOfStockProducts = (int)ViewData["OutOfStockProducts"]!; + var totalUrgentActions = pendingOrders + (lowStockProducts > 5 ? 1 : 0) + (outOfStockProducts > 0 ? 1 : 0); +} + + +@if (totalUrgentActions > 0) +{ + +} +else +{ +
+
+ +
+
+} +
@@ -104,9 +199,6 @@
Quick Actions
-
@@ -147,15 +239,29 @@ \ No newline at end of file diff --git a/LittleShop/Areas/Admin/Views/Payments/Index.cshtml b/LittleShop/Areas/Admin/Views/Payments/Index.cshtml new file mode 100644 index 0000000..e51308f --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Payments/Index.cshtml @@ -0,0 +1,260 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Payment Transactions"; + var orders = ViewData["Orders"] as Dictionary ?? new Dictionary(); + var currentStatus = ViewData["CurrentStatus"] as string ?? ""; +} + +
+
+ +
+
+ +
+
+

Payment Transactions

+

View all cryptocurrency payment transactions and their statuses

+
+
+ + + + + +
+
+
+
+
Total Transactions
+

@Model.Count()

+
+
+
+
+
+
+
Successful Payments
+

@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Paid || p.Status == LittleShop.Enums.PaymentStatus.Completed)

+
+
+
+
+
+
+
Pending
+

@Model.Count(p => p.Status == LittleShop.Enums.PaymentStatus.Pending)

+
+
+
+
+
+
+
Total Value
+

£@Model.Where(p => p.Status == LittleShop.Enums.PaymentStatus.Paid || p.Status == LittleShop.Enums.PaymentStatus.Completed).Sum(p => p.PaidAmount).ToString("N2")

+
+
+
+
+ + +@if (Model.Any()) +{ +
+
+
Transaction List
+
+
+
+ + + + + + + + + + + + + + + + @foreach (var payment in Model) + { + var statusClass = payment.Status.ToString() == "Paid" || payment.Status.ToString() == "Completed" ? "success" : + payment.Status.ToString() == "Expired" || payment.Status.ToString() == "Cancelled" ? "danger" : + payment.Status.ToString() == "Pending" ? "warning" : + "info"; + + var isExpired = DateTime.UtcNow > payment.ExpiresAt && payment.Status == LittleShop.Enums.PaymentStatus.Pending; + + + + + + + + + + + + + } + +
Payment IDOrderCurrencyRequiredPaidStatusCreatedExpires/PaidActions
+ @payment.Id.ToString().Substring(0, 8) + @if (!string.IsNullOrEmpty(payment.SilverPayOrderId)) + { +
SilverPay: @payment.SilverPayOrderId + } +
+ @if (orders.ContainsKey(payment.OrderId)) + { + var order = orders[payment.OrderId]; + + Order #@payment.OrderId.ToString().Substring(0, 8) + +
£@order.TotalAmount.ToString("N2") + } + else + { + Order #@payment.OrderId.ToString().Substring(0, 8) + } +
+ + @payment.Currency + + + @payment.RequiredAmount.ToString("0.########") +
@payment.Currency +
+ @if (payment.PaidAmount > 0) + { + @payment.PaidAmount.ToString("0.########") +
@payment.Currency + } + else + { + - + } +
+ + @if (payment.Status.ToString() == "Paid") + { + + } + else if (payment.Status.ToString() == "Pending") + { + + } + else if (payment.Status.ToString() == "Expired") + { + + } + @payment.Status + + @if (isExpired) + { +
Expired + } +
+ @payment.CreatedAt.ToString("MMM dd, yyyy") +
@payment.CreatedAt.ToString("HH:mm") +
+ @if (payment.PaidAt.HasValue) + { + + @payment.PaidAt.Value.ToString("MMM dd, HH:mm") + + } + else + { + + @payment.ExpiresAt.ToString("MMM dd, HH:mm") + + } + + @if (!string.IsNullOrEmpty(payment.TransactionHash)) + { + + } + + + +
+
+
+
+} +else +{ +
+ + @if (!string.IsNullOrEmpty(currentStatus)) + { + No @currentStatus.ToLower() payments found +

Try viewing all payments.

+ } + else + { + No payment transactions yet +

Payment transactions will appear here when customers make cryptocurrency payments.

+ } +
+} + +@section Scripts { + +} diff --git a/LittleShop/Areas/Admin/Views/Products/Create.cshtml b/LittleShop/Areas/Admin/Views/Products/Create.cshtml index b119d32..abfcb2b 100644 --- a/LittleShop/Areas/Admin/Views/Products/Create.cshtml +++ b/LittleShop/Areas/Admin/Views/Products/Create.cshtml @@ -9,6 +9,7 @@

Create Product

+

Fill in the essential details below. Additional options can be configured after creation.

@@ -18,7 +19,7 @@
@Html.AntiForgeryToken() - + @if (!ViewData.ModelState.IsValid) { } - -
- - 0 ? "is-invalid" : "")" required /> - @if(ViewData.ModelState["Name"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["Name"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
- -
- - - @if(ViewData.ModelState["Description"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["Description"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
- -
-
-
- - 0 ? "is-invalid" : "")" required /> - @if(ViewData.ModelState["Price"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["Price"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
-
-
-
- - 0 ? "is-invalid" : "")" required /> - @if(ViewData.ModelState["StockQuantity"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["StockQuantity"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
-
-
-
- - - @if(ViewData.ModelState["CategoryId"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["CategoryId"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
-
-
- -
-
-
- - 0 ? "is-invalid" : "")" required /> - @if(ViewData.ModelState["Weight"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["Weight"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
-
-
-
- - - @if(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0) - { -
- @ViewData.ModelState["WeightUnit"]?.Errors.FirstOrDefault()?.ErrorMessage -
- } -
-
-
- -
-
Product Variants (optional)
-

Add variant properties like Size, Color, or Flavor to this product.

+ +
+
+ Essential Information +
-
- - 0 ? "is-invalid" : "")" + placeholder="e.g., Wireless Noise-Cancelling Headphones" + required autofocus /> + @if(ViewData.ModelState["Name"]?.Errors.Count > 0) { - @foreach (var collection in variantCollections) - { - - } +
+ @ViewData.ModelState["Name"]?.Errors.FirstOrDefault()?.ErrorMessage +
} - - Select a reusable variant template, or leave blank for custom variants + + Great! This name is unique. + +
+ +
+
+
+ +
+ + 0 ? "is-invalid" : "")" + placeholder="10.00" + required /> + @if(ViewData.ModelState["Price"]?.Errors.Count > 0) + { +
+ @ViewData.ModelState["Price"]?.Errors.FirstOrDefault()?.ErrorMessage +
+ } +
+ Base price before multi-buy discounts +
+
+
+
+ +
+ + 0 ? "is-invalid" : "")" + placeholder="100" + required /> + @if(ViewData.ModelState["StockQuantity"]?.Errors.Count > 0) + { +
+ @ViewData.ModelState["StockQuantity"]?.Errors.FirstOrDefault()?.ErrorMessage +
+ } +
+ Current inventory available +
+
+
+
+ + + @if(ViewData.ModelState["CategoryId"]?.Errors.Count > 0) + { +
+ @ViewData.ModelState["CategoryId"]?.Errors.FirstOrDefault()?.ErrorMessage +
+ } + Helps customers find this product +
+
+
- -
- -
- - - - - -
- + +
+
+
+ + + @if(ViewData.ModelState["Description"]?.Errors.Count > 0) + { +
+ @ViewData.ModelState["Description"]?.Errors.FirstOrDefault()?.ErrorMessage +
+ } + Supports emojis and Unicode characters +
+ +
+
+
+ + 0 ? "is-invalid" : "")" + placeholder="e.g., 350" + required /> + @if(ViewData.ModelState["Weight"]?.Errors.Count > 0) + { +
+ @ViewData.ModelState["Weight"]?.Errors.FirstOrDefault()?.ErrorMessage +
+ } +
+
+
+
+ + + @if(ViewData.ModelState["WeightUnit"]?.Errors.Count > 0) + { +
+ @ViewData.ModelState["WeightUnit"]?.Errors.FirstOrDefault()?.ErrorMessage +
+ } +
+
+
+
+
-
-
-
-
Product Information
+ +
+
+
Quick Start Guide
-
    -
  • Name: Unique product identifier
  • -
  • Description: Optional, supports Unicode and emojis
  • -
  • Price: Base price in GBP
  • -
  • Stock: Current inventory quantity
  • -
  • Weight/Volume: Used for shipping calculations
  • -
  • Category: Product organization
  • -
  • Photos: Can be added after creating the product
  • +
    Essential Fields (Required)
    +
      +
    1. Product Name - Clear, descriptive title
    2. +
    3. Price - Base price in £ (GBP)
    4. +
    5. Stock Quantity - Available inventory
    6. +
    7. Category - Helps customers find it
    8. +
    + +
    Optional Sections
    +
      +
    • + + Product Details
      + Description, weight/dimensions for shipping +
    • +
    • + + Variants
      + Size, color, or other options +
    • +
    +
+
+ + +
+
+
Helpful Tips
+
+
+
    +
  • + + Photos: Add after creating the product +
  • +
  • + + Multi-buy: Configure bulk discounts later +
  • +
  • + + Smart Form: Remembers your last category +
  • +
  • + + Validation: Real-time feedback as you type +
- The form remembers your last used category and weight unit. Add photos after creation.
@@ -217,83 +338,75 @@ } \ No newline at end of file diff --git a/LittleShop/Areas/Admin/Views/Products/Edit.cshtml b/LittleShop/Areas/Admin/Views/Products/Edit.cshtml index 697273e..2580af8 100644 --- a/LittleShop/Areas/Admin/Views/Products/Edit.cshtml +++ b/LittleShop/Areas/Admin/Views/Products/Edit.cshtml @@ -493,7 +493,7 @@ Product Reviews @{ - var productReviews = ViewData["ProductReviews"] as IEnumerable; + var productReviews = ViewData["ProductReviews"] as IEnumerable; if (productReviews != null && productReviews.Any()) { @productReviews.Count() review(s) @@ -523,7 +523,7 @@
@for (int i = 1; i <= 5; i++) { - if (i <= (review.Rating ?? 0)) + if (i <= review.Rating) { } @@ -533,20 +533,20 @@ } }
- @(review.CustomerName ?? "Anonymous Customer") + @(string.IsNullOrEmpty(review.CustomerDisplayName) ? "Anonymous Customer" : review.CustomerDisplayName)
- @(review.CreatedAt?.ToString("MMM dd, yyyy") ?? "Date unknown") + @review.CreatedAt.ToString("MMM dd, yyyy")
- @(review.Rating ?? 0)/5 + @review.Rating/5
- @if (!string.IsNullOrEmpty(review.Comment?.ToString())) + @if (!string.IsNullOrEmpty(review.Comment)) {

@review.Comment

} - @if (!string.IsNullOrEmpty(review.OrderReference?.ToString())) + @if (review.IsVerifiedPurchase) { - Order: @review.OrderReference + Verified Purchase }
@@ -558,7 +558,7 @@
Average Rating
- @(productReviews.Average(r => r.Rating ?? 0).ToString("F1"))/5 + @(productReviews.Average(r => r.Rating).ToString("F1"))/5
Total Reviews
diff --git a/LittleShop/Areas/Admin/Views/PushSubscriptions/Index.cshtml b/LittleShop/Areas/Admin/Views/PushSubscriptions/Index.cshtml new file mode 100644 index 0000000..b1c70fe --- /dev/null +++ b/LittleShop/Areas/Admin/Views/PushSubscriptions/Index.cshtml @@ -0,0 +1,260 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Push Subscriptions"; +} + +
+
+ +
+
+ +
+
+

Push Subscriptions

+

Manage browser push notification subscriptions for admins and customers

+
+
+
+ @Html.AntiForgeryToken() + +
+
+
+ + +
+
+
+
+
Total Subscriptions
+

@Model.Count()

+
+
+
+
+
+
+
Active
+

@Model.Count(s => s.IsActive)

+
+
+
+
+
+
+
Admin Users
+

@Model.Count(s => s.UserId.HasValue)

+
+
+
+
+
+
+
Customers
+

@Model.Count(s => s.CustomerId.HasValue)

+
+
+
+
+ + +@if (Model.Any()) +{ +
+
+
Subscription List
+
+
+
+ + + + + + + + + + + + + + + @foreach (var subscription in Model) + { + var daysInactive = subscription.LastUsedAt.HasValue + ? (DateTime.UtcNow - subscription.LastUsedAt.Value).Days + : (DateTime.UtcNow - subscription.SubscribedAt).Days; + + var statusClass = subscription.IsActive + ? (daysInactive > 90 ? "warning" : "success") + : "danger"; + + + + + + + + + + + + } + +
IDTypeEndpointSubscribedLast UsedBrowser/DeviceStatusActions
@subscription.Id + @if (subscription.UserId.HasValue) + { + + Admin User + + @if (subscription.User != null) + { +
@subscription.User.Username + } + } + else if (subscription.CustomerId.HasValue) + { + + Customer + + @if (subscription.Customer != null) + { +
@subscription.Customer.TelegramDisplayName + } + } + else + { + + Unknown + + } +
+ + @(subscription.Endpoint.Length > 40 ? subscription.Endpoint.Substring(0, 40) + "..." : subscription.Endpoint) + + @if (!string.IsNullOrEmpty(subscription.IpAddress)) + { +
@subscription.IpAddress + } +
+ @subscription.SubscribedAt.ToString("MMM dd, yyyy") +
@subscription.SubscribedAt.ToString("HH:mm") +
+ @if (subscription.LastUsedAt.HasValue) + { + @subscription.LastUsedAt.Value.ToString("MMM dd, yyyy") +
@subscription.LastUsedAt.Value.ToString("HH:mm") + + @if (daysInactive > 0) + { +
30 ? "warning" : "info")"> + @daysInactive days ago + + } + } + else + { + Never + } +
+ @if (!string.IsNullOrEmpty(subscription.UserAgent)) + { + var ua = subscription.UserAgent; + var browser = ua.Contains("Chrome") ? "Chrome" : + ua.Contains("Firefox") ? "Firefox" : + ua.Contains("Safari") ? "Safari" : + ua.Contains("Edge") ? "Edge" : "Unknown"; + var os = ua.Contains("Windows") ? "Windows" : + ua.Contains("Mac") ? "macOS" : + ua.Contains("Linux") ? "Linux" : + ua.Contains("Android") ? "Android" : + ua.Contains("iOS") ? "iOS" : "Unknown"; + + @browser + @os +
+ @(ua.Length > 30 ? ua.Substring(0, 30) + "..." : ua) + + } + else + { + Not available + } +
+ + @if (subscription.IsActive) + { + Active + } + else + { + Inactive + } + + +
+ @Html.AntiForgeryToken() + +
+
+
+
+
+} +else +{ +
+ + No push subscriptions yet +

Push notification subscriptions will appear here when users enable browser notifications.

+
+} + + +
+
+
+
+
About Push Subscriptions
+
+
+
    +
  • Active Status: Subscriptions marked as active can receive push notifications
  • +
  • IP Address Storage: IP addresses are stored for security and duplicate detection purposes
  • +
  • Cleanup: Expired subscriptions (inactive for >90 days) can be removed using the cleanup button
  • +
  • User Agent: Browser and device information helps identify subscription sources
  • +
  • Privacy: Subscription data contains encryption keys required for Web Push API
  • +
+
+
+
+
+ +@section Scripts { + +} diff --git a/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml b/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml index 3898374..6fb0702 100644 --- a/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml +++ b/LittleShop/Areas/Admin/Views/Shared/_Layout.cshtml @@ -37,6 +37,7 @@ + @await RenderSectionAsync("Head", required: false) @@ -64,65 +65,106 @@