feat: comprehensive app improvements with Pinia state management

Backend:
- Add API keys management (create, list, delete endpoints)
- Add email verification flow (verify, resend verification)
- Add account management (profile, change password, delete account)
- Add billing/Stripe integration (checkout, portal, webhooks)
- Add GeoIP service for analytics
- Add bulk link creation and link restore endpoints
- Add QR code analytics endpoint
- Add project description field with migration
- Add QR code name and logo support with migration
- Improve QR code generator with logo overlay support
- Add rate limiting middleware
- Update tests for new functionality

Frontend:
- Refactor entire app to use Pinia for state management
- Add auth store with initialization, login, register, logout
- Add workspace store with CRUD for workspaces, projects, links,
  QR codes, domains, assets, and analytics
- Add localStorage persistence for workspace selection
- Update App.vue with proper store initialization
- Update AppLayout.vue to use store methods instead of direct API
- Refactor Projects.vue and Domains.vue to use store state/actions
- Add VerifyEmail.vue for email verification flow
- Add ForgotPassword.vue and ResetPassword.vue
- Add Settings.vue with profile, password, API keys, danger zone
- Add QRCodeDetail.vue for QR code analytics
- Add Billing.vue for subscription management
- Expand api/client.js with all new API methods
- Add workspace change watchers for automatic data refresh

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 18:53:03 -05:00
parent abf7968911
commit e7d96f5508
100 changed files with 11424 additions and 254 deletions

View File

@@ -1,81 +1,217 @@
using System.Text;
using System.Threading.RateLimiting;
using api.Data;
using api.Features.Auth.Settings;
using api.Features.Events.Services;
using api.Features.Assets.Services;
using api.Features.Email.Services;
using api.Features.Billing.Services;
using api.Features.Billing.Settings;
using api.Features.Plans.Services;
using api.Features.QRCodes.Services;
using api.Middleware;
using FastEndpoints;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/api-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.CreateLogger();
// Add cors
if (builder.Environment.IsDevelopment())
try
{
Log.Information("Starting TrakQR API");
var builder = WebApplication.CreateBuilder(args);
// Use Serilog
builder.Host.UseSerilog();
// Configure CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader()
.AllowAnyMethod();
if (builder.Environment.IsDevelopment())
{
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
else
{
// Production: configure allowed origins from config
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["https://trakqr.com"];
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}
});
});
}
// Add services to the container.
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// Register application services
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
builder.Services.AddSingleton<IQRCodeGeneratorService, QRCodeGeneratorService>();
builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>();
// Configure JWT settings
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
// Configure authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
// Configure Rate Limiting (skip in Testing environment)
var isTestingEnvironment = builder.Environment.EnvironmentName == "Testing";
builder.Services.AddRateLimiter(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Use very high limits in testing environment
var authLimit = isTestingEnvironment ? 100000 : 10;
var globalLimit = isTestingEnvironment ? 100000 : 100;
var redirectLimit = isTestingEnvironment ? 100000 : 1000;
var apiLimit = isTestingEnvironment ? 100000 : 200;
// Global rate limit for all endpoints
options.AddPolicy("global", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = globalLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
// Strict rate limit for authentication endpoints
options.AddPolicy("auth", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = authLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
// Higher limit for redirect endpoint (public, needs to be fast)
options.AddPolicy("redirect", context =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = redirectLimit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0
}));
// API rate limit for authenticated endpoints
options.AddPolicy("api", context =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: context.User?.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = apiLimit,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
TokensPerPeriod = apiLimit,
QueueLimit = 0
}));
});
// Add services to the container
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// Register application services
builder.Services.AddSingleton<IGeoIpService, GeoIpService>();
builder.Services.AddSingleton<IEventTrackingService, EventTrackingService>();
builder.Services.AddSingleton<IQrCodeGeneratorService, QrCodeGeneratorService>();
builder.Services.AddSingleton<IAssetStorageService, LocalAssetStorageService>();
builder.Services.AddSingleton<IPlanLimitsService, PlanLimitsService>();
// Configure email service
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"));
var emailProvider = builder.Configuration.GetValue<string>("Email:Provider") ?? "console";
if (emailProvider == "smtp")
{
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
}
else
{
// Use console email service for development
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>();
}
// Configure Stripe
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.AddSingleton<IStripeService, StripeService>();
// Configure JWT settings
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
// Configure authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings.Issuer,
ValidAudience = jwtSettings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret))
};
});
builder.Services.AddAuthorization();
builder.Services.AddFastEndpoints();
builder.Services.AddOpenApi();
var app = builder.Build();
// Global error handling middleware (must be first)
app.UseMiddleware<GlobalExceptionMiddleware>();
// Request logging middleware
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "{RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString());
};
});
builder.Services.AddAuthorization();
builder.Services.AddFastEndpoints();
builder.Services.AddOpenApi();
app.UseCors();
app.UseRateLimiter();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().CacheOutput();
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
}
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().CacheOutput();
app.UseFastEndpoints();
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseFastEndpoints();
app.Run();