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:
142
docs/tasks.md
142
docs/tasks.md
@@ -175,13 +175,13 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Frontend Dashboard (In Progress)
|
||||
## Phase 6: Frontend Dashboard (Complete)
|
||||
|
||||
### Authentication UI
|
||||
- [x] Login page
|
||||
- [x] Registration page
|
||||
- [ ] Forgot password page
|
||||
- [ ] Password reset page
|
||||
- [x] Forgot password page
|
||||
- [x] Password reset page
|
||||
- [x] Auth state management (Pinia store)
|
||||
|
||||
### Dashboard
|
||||
@@ -198,17 +198,17 @@
|
||||
### QR Designer UI
|
||||
- [x] QR designer page
|
||||
- [x] Color pickers
|
||||
- [~] Shape selectors (basic support)
|
||||
- [ ] Logo upload integration
|
||||
- [x] Shape selectors (Square, Rounded, Dots for modules; Square, Rounded, Circle for eyes)
|
||||
- [x] Logo upload integration (upload new or select from existing assets)
|
||||
- [x] Live preview (for saved QR codes)
|
||||
- [x] Export buttons (PNG/SVG)
|
||||
- [x] Style presets (6 presets)
|
||||
- [x] Style presets (6 presets with shape variations)
|
||||
|
||||
### Analytics UI
|
||||
- [x] Charts (time series with clicks/scans)
|
||||
- [x] Stat cards (clicks, scans, visitors, total)
|
||||
- [x] Breakdown tables (referrer, device)
|
||||
- [~] Geo breakdown (API ready, UI pending)
|
||||
- [x] Geo breakdown (country flags and names, requires MaxMind GeoIP2 database)
|
||||
|
||||
---
|
||||
|
||||
@@ -230,8 +230,7 @@
|
||||
### Plan & Quotas
|
||||
- [ ] Usage tracking
|
||||
- [ ] Plan limits enforcement
|
||||
- Free: 50 links, 1 workspace
|
||||
- Pro: 5,000 links, 5 workspaces
|
||||
- Free: 50 links, 1 workspacf - Pro: 5,000 links, 5 workspaces
|
||||
- Business: Unlimited
|
||||
- [ ] Upgrade prompts
|
||||
|
||||
@@ -297,6 +296,131 @@ Completed:
|
||||
|
||||
---
|
||||
|
||||
## Gap Analysis (Spec vs Implementation)
|
||||
|
||||
> This section identifies gaps between the MVP spec (`docs/spec.md`) and the current implementation.
|
||||
|
||||
### Authentication & Account
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Email verification | ❌ Missing | Endpoint structure exists, but no email sending or verification flow |
|
||||
| Basic account settings page | ❌ Missing | No settings UI or endpoints for profile updates |
|
||||
| SSO (optional, post-MVP) | ⏳ Deferred | As expected |
|
||||
|
||||
### Short Link Features
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| UTM builder (preset templates) | ❌ Missing | Spec mentions UTM builder for Pro plan |
|
||||
| Destination URL allowlist/denylist | ❌ Missing | Abuse prevention not implemented |
|
||||
| Soft delete for links | ❌ Missing | Currently using hard delete |
|
||||
|
||||
### QR Code Designer
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Shape presets (module shapes) | ✅ Complete | Square, Rounded, Dots module shapes supported |
|
||||
| Eye shape customization | ✅ Complete | Square, Rounded, Circle eye shapes supported |
|
||||
| Logo upload integration | ✅ Complete | Upload new or select from existing assets |
|
||||
| Logo size + margin controls | ⚠️ Partial | Fixed 20% size, no user controls |
|
||||
| Print-ready options ("high contrast" toggle) | ❌ Missing | No print optimization features |
|
||||
|
||||
### Analytics & Tracking
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Geo (country) breakdown | ✅ Complete | MaxMind GeoIP2 integrated, UI with country flags |
|
||||
| Per-QR analytics endpoint | ❌ Missing | Spec: `GET /analytics/qrcode/{id}` - only link analytics exist |
|
||||
| Scan vs Click distinction via `?qr=` param | ⚠️ Partial | Event type exists but QR export doesn't append `?qr=<id>` to URLs |
|
||||
| Custom date range filter | ❌ Missing | Only 24h/7d/30d implemented, spec mentions custom range |
|
||||
| Monthly IP salt rotation | ❌ Missing | Spec requires rotating salt for privacy compliance |
|
||||
| Event retention configuration per plan | ❌ Missing | No retention policy or cleanup jobs |
|
||||
|
||||
### Admin & Quotas
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Subscription status display | ❌ Missing | Plan field exists on Workspace but no UI |
|
||||
| Usage quotas enforcement | ❌ Missing | No limits enforced for links/QRs/events/domains |
|
||||
| Upgrade prompts | ❌ Missing | No paywall or upgrade flows |
|
||||
|
||||
### Security & Non-Functional
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Rate limiting on public endpoints | ❌ Missing | Critical for redirect endpoint |
|
||||
| CORS configuration | ❌ Missing | Needs proper configuration |
|
||||
| Strict CSP headers | ❌ Missing | App pages have no CSP |
|
||||
| Request logging | ❌ Missing | No structured logging |
|
||||
| Error handling middleware | ❌ Missing | No global error handler |
|
||||
|
||||
### Frontend UI Pages
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Forgot password page | ✅ Complete | Full UI with success state |
|
||||
| Password reset page | ✅ Complete | Full UI with token validation and success state |
|
||||
| Projects list UI | ❌ Missing | Backend CRUD complete, no frontend |
|
||||
| Domains page (add/verify) | ❌ Missing | Backend complete, no frontend |
|
||||
| Workspace switcher (full UI) | ⚠️ Partial | Basic switcher exists, no create/manage UI |
|
||||
| Per-QR analytics view | ❌ Missing | Only per-link analytics in UI |
|
||||
|
||||
### Email System
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Email service integration | ❌ Missing | No email provider configured |
|
||||
| Email verification emails | ❌ Missing | No templates or sending logic |
|
||||
| Password reset emails | ❌ Missing | Token generated but not emailed |
|
||||
| Email templates | ❌ Missing | No templating system |
|
||||
|
||||
### Background Jobs
|
||||
|
||||
| Spec Requirement | Status | Notes |
|
||||
|-----------------|--------|-------|
|
||||
| Domain verification checks | ❌ Missing | Only manual verification, no periodic checks |
|
||||
| Event enrichment (geo/device) | ✅ Complete | Device parsing and GeoIP country lookup done |
|
||||
| Cleanup & retention tasks | ❌ Missing | No scheduled cleanup for old events |
|
||||
|
||||
### API Surface Gaps
|
||||
|
||||
| Endpoint (from spec) | Status |
|
||||
|---------------------|--------|
|
||||
| `GET /analytics/qrcode/{id}` | ❌ Missing |
|
||||
| Account settings endpoints | ❌ Missing |
|
||||
| Usage/quota endpoints | ❌ Missing |
|
||||
|
||||
---
|
||||
|
||||
## Priority Gap Resolution
|
||||
|
||||
### High Priority (MVP Blockers)
|
||||
|
||||
1. **Email system** - Verification and password reset cannot work without email
|
||||
2. **Rate limiting** - Security risk without it on public redirect
|
||||
3. **QR scan tracking** - QR exports need `?qr=<id>` param for scan attribution
|
||||
4. ~~**Geo breakdown** - GeoIP integration for country-level analytics~~ ✅ Complete
|
||||
5. **Projects UI** - Backend exists, needs frontend
|
||||
|
||||
### Medium Priority (MVP Polish)
|
||||
|
||||
6. **Account settings page** - Users need to update profile
|
||||
7. **Domains UI** - Backend exists, needs frontend
|
||||
8. **Usage quotas** - Enforce plan limits
|
||||
9. ~~**QR shape presets** - More customization options~~ ✅ Complete (Square, Rounded, Dots)
|
||||
10. **Custom date range** - Analytics flexibility
|
||||
|
||||
### Lower Priority (Post-MVP)
|
||||
|
||||
11. UTM builder
|
||||
12. Soft delete for links
|
||||
13. Print-ready QR options
|
||||
14. SSO integration
|
||||
15. Stripe payments
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Backend uses FastEndpoints (not traditional MVC controllers)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
@@ -13,8 +14,36 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
|
||||
.Build();
|
||||
|
||||
private bool _containerStarted = false;
|
||||
|
||||
private void EnsureContainerStarted()
|
||||
{
|
||||
if (!_containerStarted)
|
||||
{
|
||||
_postgres.StartAsync().GetAwaiter().GetResult();
|
||||
_containerStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
// Ensure container is started before we need the connection string
|
||||
EnsureContainerStarted();
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
// Set environment variables for configuration (these take precedence)
|
||||
Environment.SetEnvironmentVariable("Jwt__Secret", "test-secret-key-min-32-characters-long-for-hmac256!");
|
||||
Environment.SetEnvironmentVariable("Jwt__Issuer", "TrakQR");
|
||||
Environment.SetEnvironmentVariable("Jwt__Audience", "TrakQR");
|
||||
Environment.SetEnvironmentVariable("Jwt__ExpirationMinutes", "60");
|
||||
Environment.SetEnvironmentVariable("Email__Provider", "console");
|
||||
Environment.SetEnvironmentVariable("Stripe__SecretKey", "sk_test_fake_key");
|
||||
Environment.SetEnvironmentVariable("Stripe__WebhookSecret", "whsec_fake_secret");
|
||||
Environment.SetEnvironmentVariable("Stripe__ProPriceId", "price_test_pro");
|
||||
Environment.SetEnvironmentVariable("Stripe__BusinessPriceId", "price_test_business");
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings__PostgresConnection", _postgres.GetConnectionString());
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
// Remove existing DbContext registration
|
||||
@@ -29,12 +58,14 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
||||
// Add DbContext with Testcontainers connection string
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(_postgres.GetConnectionString()));
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _postgres.StartAsync();
|
||||
// Ensure container is started (might already be started from ConfigureWebHost)
|
||||
EnsureContainerStarted();
|
||||
|
||||
// Run migrations
|
||||
using var scope = Services.CreateScope();
|
||||
@@ -47,4 +78,19 @@ public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, I
|
||||
await _postgres.DisposeAsync();
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
|
||||
/// </summary>
|
||||
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var workspace = await db.Workspaces.FindAsync(workspaceId);
|
||||
if (workspace != null)
|
||||
{
|
||||
workspace.Plan = api.Models.WorkspacePlan.Pro;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_ReturnsNotImplemented()
|
||||
public async Task ResetPassword_WithInvalidToken_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new { Token = "some-token", NewPassword = "newpassword123" };
|
||||
@@ -181,7 +181,7 @@ public class AuthControllerTests(ApiWebApplicationFactory factory)
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Message.Should().Be("Password reset is not yet available");
|
||||
result!.Message.Should().Be("Invalid or expired reset token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -13,7 +13,7 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email)
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = true)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
@@ -28,6 +28,12 @@ public class DomainEndpointTests(ApiWebApplicationFactory factory)
|
||||
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
|
||||
var workspaceId = workspaces!.Workspaces.First().Id;
|
||||
|
||||
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
|
||||
if (upgradeToPro)
|
||||
{
|
||||
await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||
}
|
||||
|
||||
return (token, workspaceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com")
|
||||
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email, bool upgradeToPro = false)
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
@@ -20,7 +20,25 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
|
||||
}
|
||||
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||
return result!.Token;
|
||||
var token = result!.Token;
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var workspacesResponse = await _client.GetAsync("/workspaces");
|
||||
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
|
||||
var workspaceId = workspaces!.Workspaces.First().Id;
|
||||
|
||||
if (upgradeToPro)
|
||||
{
|
||||
await factory.UpgradeWorkspaceToPro(workspaceId);
|
||||
}
|
||||
|
||||
return (token, workspaceId);
|
||||
}
|
||||
|
||||
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com")
|
||||
{
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync(email);
|
||||
return token;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -53,8 +71,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
[Fact]
|
||||
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var token = await GetAuthTokenAsync("create-ws@example.com");
|
||||
// Arrange - upgrade to Pro to allow creating additional workspaces
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", upgradeToPro: true);
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
// Act
|
||||
@@ -86,21 +104,18 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
[Fact]
|
||||
public async Task GetWorkspace_WithValidId_ReturnsWorkspace()
|
||||
{
|
||||
// Arrange
|
||||
var token = await GetAuthTokenAsync("get-ws@example.com");
|
||||
// Arrange - use the default workspace (created on registration)
|
||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-ws@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Get Test" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/workspaces/{created!.Id}");
|
||||
var response = await _client.GetAsync($"/workspaces/{workspaceId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
|
||||
result!.Id.Should().Be(created.Id);
|
||||
result.Name.Should().Be("Get Test");
|
||||
result!.Id.Should().Be(workspaceId);
|
||||
result.Name.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,15 +135,12 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
[Fact]
|
||||
public async Task UpdateWorkspace_WithValidData_ReturnsUpdated()
|
||||
{
|
||||
// Arrange
|
||||
var token = await GetAuthTokenAsync("update-ws@example.com");
|
||||
// Arrange - use the default workspace (created on registration)
|
||||
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("update-ws@example.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Original Name" });
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.PutAsJsonAsync($"/workspaces/{created!.Id}", new { Name = "Updated Name" });
|
||||
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}", new { Name = "Updated Name" });
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
@@ -139,8 +151,8 @@ public class WorkspaceEndpointTests(ApiWebApplicationFactory factory)
|
||||
[Fact]
|
||||
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var token = await GetAuthTokenAsync("delete-ws@example.com");
|
||||
// Arrange - upgrade to Pro to allow creating additional workspaces
|
||||
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", upgradeToPro: true);
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
|
||||
|
||||
@@ -14,6 +14,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<QRCodeDesign> QrCodeDesigns => Set<QRCodeDesign>();
|
||||
public DbSet<Event> Events => Set<Event>();
|
||||
public DbSet<Asset> Assets => Set<Asset>();
|
||||
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
|
||||
public DbSet<EmailVerificationToken> EmailVerificationTokens => Set<EmailVerificationToken>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -177,5 +180,49 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// PasswordResetToken configuration
|
||||
modelBuilder.Entity<PasswordResetToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Token).IsUnique();
|
||||
entity.Property(e => e.Token).HasMaxLength(64);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// EmailVerificationToken configuration
|
||||
modelBuilder.Entity<EmailVerificationToken>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Token).IsUnique();
|
||||
entity.Property(e => e.Token).HasMaxLength(64);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ApiKey configuration
|
||||
modelBuilder.Entity<ApiKey>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.KeyHash).IsUnique();
|
||||
entity.Property(e => e.Name).HasMaxLength(100);
|
||||
entity.Property(e => e.KeyHash).HasMaxLength(64);
|
||||
entity.Property(e => e.KeyPrefix).HasMaxLength(16);
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
entity.HasOne(e => e.Workspace)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WorkspaceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ public record WorkspaceAnalyticsResponse(
|
||||
IEnumerable<TimeSeriesPoint> TimeSeries,
|
||||
IEnumerable<BreakdownItem> TopLinks,
|
||||
IEnumerable<BreakdownItem> DeviceBreakdown,
|
||||
IEnumerable<BreakdownItem> ReferrerBreakdown
|
||||
IEnumerable<BreakdownItem> ReferrerBreakdown,
|
||||
IEnumerable<BreakdownItem> CountryBreakdown
|
||||
);
|
||||
|
||||
public record LinkAnalyticsResponse(
|
||||
@@ -34,5 +35,6 @@ public record LinkAnalyticsResponse(
|
||||
AnalyticsSummary Summary,
|
||||
IEnumerable<TimeSeriesPoint> TimeSeries,
|
||||
IEnumerable<BreakdownItem> DeviceBreakdown,
|
||||
IEnumerable<BreakdownItem> ReferrerBreakdown
|
||||
IEnumerable<BreakdownItem> ReferrerBreakdown,
|
||||
IEnumerable<BreakdownItem> CountryBreakdown
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ public class LinkAnalyticsRequest
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
|
||||
public DateTime? StartDate { get; set; } // Custom date range start
|
||||
public DateTime? EndDate { get; set; } // Custom date range end
|
||||
}
|
||||
|
||||
public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
@@ -39,8 +41,19 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine time filter
|
||||
var startDate = GetStartDate(req.Period);
|
||||
// Determine time filter (custom range takes precedence over period)
|
||||
DateTime? startDate = null;
|
||||
DateTime? endDate = null;
|
||||
|
||||
if (req.StartDate.HasValue && req.EndDate.HasValue)
|
||||
{
|
||||
startDate = req.StartDate.Value;
|
||||
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
|
||||
}
|
||||
else
|
||||
{
|
||||
startDate = GetStartDate(req.Period);
|
||||
}
|
||||
|
||||
// Query events for this link
|
||||
var eventsQuery = db.Events
|
||||
@@ -51,6 +64,11 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
}
|
||||
|
||||
if (endDate.HasValue)
|
||||
{
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
}
|
||||
|
||||
var events = await eventsQuery.ToListAsync(ct);
|
||||
var totalEvents = events.Count;
|
||||
|
||||
@@ -99,13 +117,27 @@ public class LinkAnalyticsEndpoint(AppDbContext db)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Country breakdown
|
||||
var countryBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
|
||||
.GroupBy(e => e.CountryCode!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var response = new LinkAnalyticsResponse(
|
||||
LinkId: link.Id,
|
||||
Slug: link.Slug,
|
||||
Summary: summary,
|
||||
TimeSeries: timeSeries,
|
||||
DeviceBreakdown: deviceBreakdown,
|
||||
ReferrerBreakdown: referrerBreakdown
|
||||
ReferrerBreakdown: referrerBreakdown,
|
||||
CountryBreakdown: countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
|
||||
@@ -12,6 +12,8 @@ public class WorkspaceAnalyticsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public string? Period { get; set; } // 24h, 7d, 30d, or null for all time
|
||||
public DateTime? StartDate { get; set; } // Custom date range start
|
||||
public DateTime? EndDate { get; set; } // Custom date range end
|
||||
}
|
||||
|
||||
public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
@@ -36,8 +38,19 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine time filter
|
||||
var startDate = GetStartDate(req.Period);
|
||||
// Determine time filter (custom range takes precedence over period)
|
||||
DateTime? startDate = null;
|
||||
DateTime? endDate = null;
|
||||
|
||||
if (req.StartDate.HasValue && req.EndDate.HasValue)
|
||||
{
|
||||
startDate = req.StartDate.Value;
|
||||
endDate = req.EndDate.Value.AddDays(1); // Include the entire end day
|
||||
}
|
||||
else
|
||||
{
|
||||
startDate = GetStartDate(req.Period);
|
||||
}
|
||||
|
||||
// Query events
|
||||
var eventsQuery = db.Events
|
||||
@@ -48,6 +61,11 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp >= startDate.Value);
|
||||
}
|
||||
|
||||
if (endDate.HasValue)
|
||||
{
|
||||
eventsQuery = eventsQuery.Where(e => e.Timestamp < endDate.Value);
|
||||
}
|
||||
|
||||
var events = await eventsQuery.ToListAsync(ct);
|
||||
var totalEvents = events.Count;
|
||||
|
||||
@@ -114,12 +132,26 @@ public class WorkspaceAnalyticsEndpoint(AppDbContext db)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
// Get country breakdown
|
||||
var countryBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
|
||||
.GroupBy(e => e.CountryCode!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.Select(g => new BreakdownItem(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
totalEvents > 0 ? Math.Round((double)g.Count() / totalEvents * 100, 1) : 0
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var response = new WorkspaceAnalyticsResponse(
|
||||
Summary: summary,
|
||||
TimeSeries: timeSeries,
|
||||
TopLinks: topLinks,
|
||||
DeviceBreakdown: deviceBreakdown,
|
||||
ReferrerBreakdown: referrerBreakdown
|
||||
ReferrerBreakdown: referrerBreakdown,
|
||||
CountryBreakdown: countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
|
||||
102
src/api/Features/ApiKeys/Endpoints/CreateApiKeyEndpoint.cs
Normal file
102
src/api/Features/ApiKeys/Endpoints/CreateApiKeyEndpoint.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class CreateApiKeyRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
}
|
||||
|
||||
public class CreateApiKeyResponse
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Key { get; set; } // Only returned once on creation!
|
||||
public required string KeyPrefix { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateApiKeyEndpoint(AppDbContext db)
|
||||
: Endpoint<CreateApiKeyRequest, CreateApiKeyResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/api-keys");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateApiKeyRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check API key limit (max 10 per workspace)
|
||||
var existingCount = await db.ApiKeys.CountAsync(k => k.WorkspaceId == req.WorkspaceId && k.IsActive, ct);
|
||||
if (existingCount >= 10)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 10 API keys per workspace"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate secure key: trk_<32 random bytes as base64url>
|
||||
var randomBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var keyValue = "trk_" + Convert.ToBase64String(randomBytes).Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
var keyHash = ComputeSha256Hash(keyValue);
|
||||
var keyPrefix = keyValue[..12] + "...";
|
||||
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Name = req.Name,
|
||||
KeyHash = keyHash,
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = req.Scopes,
|
||||
ExpiresAt = req.ExpiresAt,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new CreateApiKeyResponse
|
||||
{
|
||||
Id = apiKey.Id,
|
||||
Name = apiKey.Name,
|
||||
Key = keyValue, // Only returned once!
|
||||
KeyPrefix = keyPrefix,
|
||||
Scopes = apiKey.Scopes,
|
||||
ExpiresAt = apiKey.ExpiresAt,
|
||||
CreatedAt = apiKey.CreatedAt,
|
||||
};
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hash(string input)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLower();
|
||||
}
|
||||
}
|
||||
51
src/api/Features/ApiKeys/Endpoints/DeleteApiKeyEndpoint.cs
Normal file
51
src/api/Features/ApiKeys/Endpoints/DeleteApiKeyEndpoint.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class DeleteApiKeyRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class DeleteApiKeyEndpoint(AppDbContext db)
|
||||
: Endpoint<DeleteApiKeyRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/workspaces/{WorkspaceId}/api-keys/{Id}");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteApiKeyRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var apiKey = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == req.Id && k.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (apiKey is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("API key not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
db.ApiKeys.Remove(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("API key deleted"), 200, cancellation: ct);
|
||||
}
|
||||
}
|
||||
72
src/api/Features/ApiKeys/Endpoints/ListApiKeysEndpoint.cs
Normal file
72
src/api/Features/ApiKeys/Endpoints/ListApiKeysEndpoint.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.ApiKeys.Endpoints;
|
||||
|
||||
public class ListApiKeysRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class ApiKeyDto
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string KeyPrefix { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class ListApiKeysResponse
|
||||
{
|
||||
public required List<ApiKeyDto> ApiKeys { get; set; }
|
||||
}
|
||||
|
||||
public class ListApiKeysEndpoint(AppDbContext db)
|
||||
: Endpoint<ListApiKeysRequest, ListApiKeysResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/api-keys");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ListApiKeysRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspaceExists = await db.Workspaces
|
||||
.AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (!workspaceExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var apiKeys = await db.ApiKeys
|
||||
.Where(k => k.WorkspaceId == req.WorkspaceId)
|
||||
.OrderByDescending(k => k.CreatedAt)
|
||||
.Select(k => new ApiKeyDto
|
||||
{
|
||||
Id = k.Id,
|
||||
Name = k.Name,
|
||||
KeyPrefix = k.KeyPrefix,
|
||||
Scopes = k.Scopes,
|
||||
ExpiresAt = k.ExpiresAt,
|
||||
LastUsedAt = k.LastUsedAt,
|
||||
CreatedAt = k.CreatedAt,
|
||||
IsActive = k.IsActive,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var response = new ListApiKeysResponse { ApiKeys = apiKeys };
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
}
|
||||
}
|
||||
59
src/api/Features/Auth/Endpoints/ChangePasswordEndpoint.cs
Normal file
59
src/api/Features/Auth/Endpoints/ChangePasswordEndpoint.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
public class ChangePasswordRequest
|
||||
{
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ChangePasswordValidator : Validator<ChangePasswordRequest>
|
||||
{
|
||||
public ChangePasswordValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword)
|
||||
.NotEmpty().WithMessage("Current password is required");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty().WithMessage("New password is required")
|
||||
.MinimumLength(8).WithMessage("New password must be at least 8 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangePasswordEndpoint(AppDbContext db) : Endpoint<ChangePasswordRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/change-password");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangePasswordRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Current password is incorrect"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update password
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Password changed successfully"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
59
src/api/Features/Auth/Endpoints/DeleteAccountEndpoint.cs
Normal file
59
src/api/Features/Auth/Endpoints/DeleteAccountEndpoint.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
public class DeleteAccountRequest
|
||||
{
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class DeleteAccountValidator : Validator<DeleteAccountRequest>
|
||||
{
|
||||
public DeleteAccountValidator()
|
||||
{
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty().WithMessage("Password is required to delete account");
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteAccountEndpoint(AppDbContext db) : Endpoint<DeleteAccountRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/auth/account");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(DeleteAccountRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Password is incorrect"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all user's workspaces (cascade will handle related data)
|
||||
var workspaces = await db.Workspaces.Where(w => w.OwnerUserId == userId).ToListAsync(ct);
|
||||
db.Workspaces.RemoveRange(workspaces);
|
||||
|
||||
// Delete user
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Account deleted successfully"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Email.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -22,13 +24,14 @@ public class ForgotPasswordValidator : Validator<ForgotPasswordRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class ForgotPasswordEndpoint(AppDbContext db)
|
||||
public class ForgotPasswordEndpoint(AppDbContext db, IEmailService emailService)
|
||||
: Endpoint<ForgotPasswordRequest, MessageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/forgot");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct)
|
||||
@@ -36,20 +39,55 @@ public class ForgotPasswordEndpoint(AppDbContext db)
|
||||
var normalizedEmail = req.Email.ToLowerInvariant();
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct);
|
||||
|
||||
if (user == null)
|
||||
if (user != null)
|
||||
{
|
||||
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
|
||||
// Invalidate any existing tokens for this user
|
||||
var existingTokens = await db.PasswordResetTokens
|
||||
.Where(t => t.UserId == user.Id && !t.Used)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var token in existingTokens)
|
||||
{
|
||||
token.Used = true;
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
|
||||
var passwordResetToken = new PasswordResetToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
Token = resetToken,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(1),
|
||||
Used = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.PasswordResetTokens.Add(passwordResetToken);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Send password reset email
|
||||
try
|
||||
{
|
||||
await emailService.SendPasswordResetEmailAsync(normalizedEmail, resetToken, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to send password reset email to {Email}", normalizedEmail);
|
||||
// Don't fail the request - still return success to prevent email enumeration
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var resetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
|
||||
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
|
||||
// TODO: Store reset token in database with expiration
|
||||
// TODO: Send email with reset link
|
||||
Logger.LogInformation("Password reset token generated for: {Email}, Token: {Token}", normalizedEmail, resetToken);
|
||||
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("If the email exists, a reset link will be sent"), 200, cancellation: ct);
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("If the email exists, a reset link will be sent"),
|
||||
200,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
44
src/api/Features/Auth/Endpoints/GetProfileEndpoint.cs
Normal file
44
src/api/Features/Auth/Endpoints/GetProfileEndpoint.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
public record ProfileResponse(
|
||||
Guid Id,
|
||||
string Email,
|
||||
bool IsVerified,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public class GetProfileEndpoint(AppDbContext db) : EndpointWithoutRequest<ProfileResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/auth/profile");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users
|
||||
.Where(u => u.Id == userId)
|
||||
.Select(u => new ProfileResponse(
|
||||
u.Id,
|
||||
u.Email,
|
||||
u.VerifiedAt != null,
|
||||
u.CreatedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new { message = "User not found" }, 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(user, cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
|
||||
{
|
||||
Post("/auth/login");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(LoginRequest req, CancellationToken ct)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Auth.Settings;
|
||||
using api.Features.Email.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
@@ -35,7 +37,7 @@ public class RegisterValidator : Validator<RegisterRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
|
||||
public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings, IEmailService emailService)
|
||||
: Endpoint<RegisterRequest, AuthResponse>
|
||||
{
|
||||
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
|
||||
@@ -44,6 +46,7 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
|
||||
{
|
||||
Post("/auth/register");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RegisterRequest req, CancellationToken ct)
|
||||
@@ -76,8 +79,29 @@ public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings
|
||||
};
|
||||
|
||||
db.Workspaces.Add(workspace);
|
||||
|
||||
// Create email verification token
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
|
||||
|
||||
var verificationToken = new EmailVerificationToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
Token = tokenString,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.EmailVerificationTokens.Add(verificationToken);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Send verification email (fire and forget)
|
||||
_ = emailService.SendEmailVerificationAsync(normalizedEmail, tokenString, ct);
|
||||
|
||||
// Send welcome email
|
||||
_ = emailService.SendWelcomeEmailAsync(normalizedEmail, normalizedEmail.Split('@')[0], ct);
|
||||
|
||||
Logger.LogInformation("User registered: {Email}", normalizedEmail);
|
||||
|
||||
var response = GenerateAuthResponse(user);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Email.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
public class ResendVerificationEndpoint(AppDbContext db, IEmailService emailService) : EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/resend-verification");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.VerifiedAt != null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already verified"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing tokens
|
||||
var existingTokens = await db.EmailVerificationTokens
|
||||
.Where(t => t.UserId == userId)
|
||||
.ToListAsync(ct);
|
||||
db.EmailVerificationTokens.RemoveRange(existingTokens);
|
||||
|
||||
// Create new token
|
||||
var tokenBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var tokenString = Convert.ToHexString(tokenBytes).ToLowerInvariant();
|
||||
|
||||
var verificationToken = new EmailVerificationToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Token = tokenString,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.EmailVerificationTokens.Add(verificationToken);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Send verification email
|
||||
await emailService.SendEmailVerificationAsync(user.Email, tokenString, ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification email sent"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
@@ -24,23 +26,63 @@ public class ValidatorResetPassword : Validator<ResetPasswordRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class ResetPasswordEndpoint : Endpoint<ResetPasswordRequest, MessageResponse>
|
||||
public class ResetPasswordEndpoint(AppDbContext db)
|
||||
: Endpoint<ResetPasswordRequest, MessageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/reset");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("auth"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ResetPasswordRequest req, CancellationToken ct)
|
||||
{
|
||||
// TODO: Implement password reset
|
||||
// 1. Look up token in database
|
||||
// 2. Verify token hasn't expired
|
||||
// 3. Get associated user
|
||||
// 4. Update password
|
||||
// 5. Invalidate token
|
||||
// Find the token
|
||||
var resetToken = await db.PasswordResetTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Password reset is not yet available"), 400, cancellation: ct);
|
||||
if (resetToken == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Invalid or expired reset token"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (resetToken.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Reset token has expired"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is already used
|
||||
if (resetToken.Used)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Reset token has already been used"),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the user's password
|
||||
resetToken.User.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||
resetToken.Used = true;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
Logger.LogInformation("Password reset successful for user: {Email}", resetToken.User.Email);
|
||||
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Password has been reset successfully"),
|
||||
200,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
68
src/api/Features/Auth/Endpoints/UpdateProfileEndpoint.cs
Normal file
68
src/api/Features/Auth/Endpoints/UpdateProfileEndpoint.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
public class UpdateProfileRequest
|
||||
{
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProfileValidator : Validator<UpdateProfileRequest>
|
||||
{
|
||||
public UpdateProfileValidator()
|
||||
{
|
||||
RuleFor(x => x.Email)
|
||||
.EmailAddress().WithMessage("Invalid email address")
|
||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateProfileEndpoint(AppDbContext db) : Endpoint<UpdateProfileRequest, ProfileResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/auth/profile");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateProfileRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct);
|
||||
if (user == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("User not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(req.Email) && req.Email != user.Email)
|
||||
{
|
||||
// Check if email is already taken
|
||||
var emailExists = await db.Users.AnyAsync(u => u.Email == req.Email && u.Id != userId, ct);
|
||||
if (emailExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email is already in use"), 409, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
user.Email = req.Email;
|
||||
user.VerifiedAt = null; // Reset verification when email changes
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new ProfileResponse(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.VerifiedAt != null,
|
||||
user.CreatedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
59
src/api/Features/Auth/Endpoints/VerifyEmailEndpoint.cs
Normal file
59
src/api/Features/Auth/Endpoints/VerifyEmailEndpoint.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Auth.Endpoints;
|
||||
|
||||
public class VerifyEmailRequest
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class VerifyEmailValidator : Validator<VerifyEmailRequest>
|
||||
{
|
||||
public VerifyEmailValidator()
|
||||
{
|
||||
RuleFor(x => x.Token).NotEmpty().WithMessage("Token is required");
|
||||
}
|
||||
}
|
||||
|
||||
public class VerifyEmailEndpoint(AppDbContext db) : Endpoint<VerifyEmailRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/auth/verify-email");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(VerifyEmailRequest req, CancellationToken ct)
|
||||
{
|
||||
var token = await db.EmailVerificationTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Token == req.Token, ct);
|
||||
|
||||
if (token == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Invalid verification token"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token.ExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
db.EmailVerificationTokens.Remove(token);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Verification token has expired"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark user as verified
|
||||
token.User.VerifiedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove the token
|
||||
db.EmailVerificationTokens.Remove(token);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Email verified successfully"), cancellation: ct);
|
||||
}
|
||||
}
|
||||
23
src/api/Features/Billing/Common/BillingModels.cs
Normal file
23
src/api/Features/Billing/Common/BillingModels.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace api.Features.Billing.Common;
|
||||
|
||||
public record CheckoutSessionRequest(
|
||||
Guid WorkspaceId,
|
||||
string Plan,
|
||||
string SuccessUrl,
|
||||
string CancelUrl
|
||||
);
|
||||
|
||||
public record CheckoutSessionResponse(string Url);
|
||||
|
||||
public record PortalSessionRequest(string ReturnUrl);
|
||||
|
||||
public record PortalSessionResponse(string Url);
|
||||
|
||||
public record SubscriptionResponse(
|
||||
Guid WorkspaceId,
|
||||
string Plan,
|
||||
string? SubscriptionId,
|
||||
DateTime? CurrentPeriodEnd,
|
||||
bool IsActive,
|
||||
bool CancelAtPeriodEnd
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Billing.Common;
|
||||
using api.Features.Billing.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
|
||||
public class CreateCheckoutSessionValidator : Validator<CheckoutSessionRequest>
|
||||
{
|
||||
public CreateCheckoutSessionValidator()
|
||||
{
|
||||
RuleFor(x => x.WorkspaceId).NotEmpty();
|
||||
RuleFor(x => x.Plan)
|
||||
.NotEmpty()
|
||||
.Must(p => p == "Pro" || p == "Business")
|
||||
.WithMessage("Plan must be 'Pro' or 'Business'");
|
||||
RuleFor(x => x.SuccessUrl).NotEmpty().Must(BeValidUrl);
|
||||
RuleFor(x => x.CancelUrl).NotEmpty().Must(BeValidUrl);
|
||||
}
|
||||
|
||||
private static bool BeValidUrl(string url)
|
||||
{
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateCheckoutSessionEndpoint(AppDbContext db, IStripeService stripeService)
|
||||
: Endpoint<CheckoutSessionRequest, CheckoutSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/billing/checkout");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CheckoutSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Workspace already has an active subscription. Use the billing portal to manage it."),
|
||||
400,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var plan = Enum.Parse<WorkspacePlan>(req.Plan);
|
||||
|
||||
try
|
||||
{
|
||||
var checkoutUrl = await stripeService.CreateCheckoutSessionAsync(
|
||||
userId,
|
||||
req.WorkspaceId,
|
||||
plan,
|
||||
req.SuccessUrl,
|
||||
req.CancelUrl,
|
||||
ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new CheckoutSessionResponse(checkoutUrl), cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse($"Failed to create checkout session: {ex.Message}"),
|
||||
500,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Security.Claims;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Billing.Common;
|
||||
using api.Features.Billing.Services;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
|
||||
public class CreatePortalSessionValidator : Validator<PortalSessionRequest>
|
||||
{
|
||||
public CreatePortalSessionValidator()
|
||||
{
|
||||
RuleFor(x => x.ReturnUrl).NotEmpty().Must(BeValidUrl);
|
||||
}
|
||||
|
||||
private static bool BeValidUrl(string url)
|
||||
{
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreatePortalSessionEndpoint(IStripeService stripeService)
|
||||
: Endpoint<PortalSessionRequest, PortalSessionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/billing/portal");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(PortalSessionRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
try
|
||||
{
|
||||
var portalUrl = await stripeService.CreateCustomerPortalSessionAsync(
|
||||
userId,
|
||||
req.ReturnUrl,
|
||||
ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new PortalSessionResponse(portalUrl), cancellation: ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse(ex.Message),
|
||||
400,
|
||||
cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse($"Failed to create portal session: {ex.Message}"),
|
||||
500,
|
||||
cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Billing.Common;
|
||||
using api.Features.Billing.Services;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
|
||||
public class GetSubscriptionRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public class GetSubscriptionEndpoint(AppDbContext db, IStripeService stripeService)
|
||||
: Endpoint<GetSubscriptionRequest, SubscriptionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/subscription");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetSubscriptionRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var isActive = workspace.Plan != Models.WorkspacePlan.Free;
|
||||
var cancelAtPeriodEnd = false;
|
||||
|
||||
// Get live subscription status from Stripe if exists
|
||||
if (!string.IsNullOrEmpty(workspace.StripeSubscriptionId))
|
||||
{
|
||||
var subscription = await stripeService.GetSubscriptionAsync(workspace.StripeSubscriptionId, ct);
|
||||
if (subscription != null)
|
||||
{
|
||||
isActive = subscription.Status == "active" || subscription.Status == "trialing";
|
||||
cancelAtPeriodEnd = subscription.CancelAtPeriodEnd;
|
||||
}
|
||||
}
|
||||
|
||||
var response = new SubscriptionResponse(
|
||||
workspace.Id,
|
||||
workspace.Plan.ToString(),
|
||||
workspace.StripeSubscriptionId,
|
||||
workspace.SubscriptionEndsAt,
|
||||
isActive,
|
||||
cancelAtPeriodEnd
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
87
src/api/Features/Billing/Endpoints/StripeWebhookEndpoint.cs
Normal file
87
src/api/Features/Billing/Endpoints/StripeWebhookEndpoint.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using api.Features.Billing.Services;
|
||||
using api.Features.Billing.Settings;
|
||||
using FastEndpoints;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
|
||||
namespace api.Features.Billing.Endpoints;
|
||||
|
||||
public class StripeWebhookEndpoint(
|
||||
IStripeService stripeService,
|
||||
IOptions<StripeSettings> settings,
|
||||
ILogger<StripeWebhookEndpoint> logger)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
private readonly StripeSettings _settings = settings.Value;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/billing/webhook");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var stripeSignature = HttpContext.Request.Headers["Stripe-Signature"].ToString();
|
||||
var stripeEvent = EventUtility.ConstructEvent(
|
||||
json,
|
||||
stripeSignature,
|
||||
_settings.WebhookSecret
|
||||
);
|
||||
|
||||
logger.LogInformation("Received Stripe event: {EventType} ({EventId})", stripeEvent.Type, stripeEvent.Id);
|
||||
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case "checkout.session.completed":
|
||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||
if (session != null)
|
||||
{
|
||||
await stripeService.HandleCheckoutCompletedAsync(session, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
var updatedSubscription = stripeEvent.Data.Object as Subscription;
|
||||
if (updatedSubscription != null)
|
||||
{
|
||||
await stripeService.HandleSubscriptionUpdatedAsync(updatedSubscription, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
var deletedSubscription = stripeEvent.Data.Object as Subscription;
|
||||
if (deletedSubscription != null)
|
||||
{
|
||||
await stripeService.HandleSubscriptionDeletedAsync(deletedSubscription, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "invoice.payment_failed":
|
||||
logger.LogWarning("Payment failed for invoice: {InvoiceId}",
|
||||
(stripeEvent.Data.Object as Invoice)?.Id);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.LogDebug("Unhandled Stripe event type: {EventType}", stripeEvent.Type);
|
||||
break;
|
||||
}
|
||||
|
||||
await HttpContext.Response.SendAsync(new { received = true }, cancellation: ct);
|
||||
}
|
||||
catch (StripeException ex)
|
||||
{
|
||||
logger.LogError(ex, "Stripe webhook signature verification failed");
|
||||
await HttpContext.Response.SendAsync(new { error = "Webhook signature verification failed" }, 400, cancellation: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing Stripe webhook");
|
||||
await HttpContext.Response.SendAsync(new { error = "Webhook processing failed" }, 500, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
302
src/api/Features/Billing/Services/StripeService.cs
Normal file
302
src/api/Features/Billing/Services/StripeService.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using api.Data;
|
||||
using api.Features.Billing.Settings;
|
||||
using api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace api.Features.Billing.Services;
|
||||
|
||||
public interface IStripeService
|
||||
{
|
||||
Task<string> CreateCheckoutSessionAsync(Guid userId, Guid workspaceId, WorkspacePlan plan, string successUrl, string cancelUrl, CancellationToken ct = default);
|
||||
Task<string> CreateCustomerPortalSessionAsync(Guid userId, string returnUrl, CancellationToken ct = default);
|
||||
Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||
Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default);
|
||||
Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default);
|
||||
Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default);
|
||||
Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default);
|
||||
string GetPriceIdForPlan(WorkspacePlan plan);
|
||||
WorkspacePlan GetPlanForPriceId(string priceId);
|
||||
}
|
||||
|
||||
public class StripeService : IStripeService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly StripeSettings _settings;
|
||||
private readonly ILogger<StripeService> _logger;
|
||||
|
||||
public StripeService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<StripeSettings> settings,
|
||||
ILogger<StripeService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
StripeConfiguration.ApiKey = _settings.SecretKey;
|
||||
}
|
||||
|
||||
public async Task<string> CreateCheckoutSessionAsync(
|
||||
Guid userId,
|
||||
Guid workspaceId,
|
||||
WorkspacePlan plan,
|
||||
string successUrl,
|
||||
string cancelUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct)
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
|
||||
// Get or create Stripe customer
|
||||
var customerId = user.StripeCustomerId;
|
||||
if (string.IsNullOrEmpty(customerId))
|
||||
{
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.CreateAsync(new CustomerCreateOptions
|
||||
{
|
||||
Email = user.Email,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = userId.ToString()
|
||||
}
|
||||
}, cancellationToken: ct);
|
||||
|
||||
customerId = customer.Id;
|
||||
user.StripeCustomerId = customerId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
var priceId = GetPriceIdForPlan(plan);
|
||||
if (string.IsNullOrEmpty(priceId))
|
||||
{
|
||||
throw new InvalidOperationException($"No price configured for plan: {plan}");
|
||||
}
|
||||
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Customer = customerId,
|
||||
Mode = "subscription",
|
||||
PaymentMethodTypes = ["card"],
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Price = priceId,
|
||||
Quantity = 1
|
||||
}
|
||||
],
|
||||
SuccessUrl = successUrl + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
CancelUrl = cancelUrl,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = userId.ToString(),
|
||||
["workspace_id"] = workspaceId.ToString(),
|
||||
["plan"] = plan.ToString()
|
||||
},
|
||||
SubscriptionData = new SessionSubscriptionDataOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["user_id"] = userId.ToString(),
|
||||
["workspace_id"] = workspaceId.ToString()
|
||||
}
|
||||
}
|
||||
}, cancellationToken: ct);
|
||||
|
||||
return session.Url;
|
||||
}
|
||||
|
||||
public async Task<string> CreateCustomerPortalSessionAsync(
|
||||
Guid userId,
|
||||
string returnUrl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var user = await db.Users.FindAsync([userId], ct)
|
||||
?? throw new InvalidOperationException("User not found");
|
||||
|
||||
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
||||
{
|
||||
throw new InvalidOperationException("User has no Stripe customer");
|
||||
}
|
||||
|
||||
var sessionService = new Stripe.BillingPortal.SessionService();
|
||||
var session = await sessionService.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
|
||||
{
|
||||
Customer = user.StripeCustomerId,
|
||||
ReturnUrl = returnUrl
|
||||
}, cancellationToken: ct);
|
||||
|
||||
return session.Url;
|
||||
}
|
||||
|
||||
public async Task<Subscription?> GetSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
|
||||
{
|
||||
var service = new SubscriptionService();
|
||||
try
|
||||
{
|
||||
return await service.GetAsync(subscriptionId, cancellationToken: ct);
|
||||
}
|
||||
catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelSubscriptionAsync(string subscriptionId, CancellationToken ct = default)
|
||||
{
|
||||
var service = new SubscriptionService();
|
||||
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions
|
||||
{
|
||||
InvoiceNow = false,
|
||||
Prorate = false
|
||||
}, cancellationToken: ct);
|
||||
}
|
||||
|
||||
public async Task HandleCheckoutCompletedAsync(Session session, CancellationToken ct = default)
|
||||
{
|
||||
var workspaceIdStr = session.Metadata.GetValueOrDefault("workspace_id");
|
||||
var planStr = session.Metadata.GetValueOrDefault("plan");
|
||||
|
||||
if (string.IsNullOrEmpty(workspaceIdStr) || string.IsNullOrEmpty(planStr))
|
||||
{
|
||||
_logger.LogWarning("Checkout session missing metadata: {SessionId}", session.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(workspaceIdStr, out var workspaceId))
|
||||
{
|
||||
_logger.LogWarning("Invalid workspace_id in checkout session: {WorkspaceId}", workspaceIdStr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<WorkspacePlan>(planStr, out var plan))
|
||||
{
|
||||
_logger.LogWarning("Invalid plan in checkout session: {Plan}", planStr);
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces.FindAsync([workspaceId], ct);
|
||||
if (workspace == null)
|
||||
{
|
||||
_logger.LogWarning("Workspace not found for checkout: {WorkspaceId}", workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.Plan = plan;
|
||||
workspace.StripeSubscriptionId = session.SubscriptionId;
|
||||
|
||||
// Get subscription to set end date
|
||||
if (!string.IsNullOrEmpty(session.SubscriptionId))
|
||||
{
|
||||
var subscription = await GetSubscriptionAsync(session.SubscriptionId, ct);
|
||||
if (subscription != null)
|
||||
{
|
||||
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} upgraded to {Plan} via checkout {SessionId}",
|
||||
workspaceId, plan, session.Id);
|
||||
}
|
||||
|
||||
public async Task HandleSubscriptionUpdatedAsync(Subscription subscription, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
_logger.LogWarning("No workspace found for subscription: {SubscriptionId}", subscription.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update plan based on price
|
||||
var priceId = subscription.Items.Data.FirstOrDefault()?.Price?.Id;
|
||||
if (!string.IsNullOrEmpty(priceId))
|
||||
{
|
||||
var newPlan = GetPlanForPriceId(priceId);
|
||||
if (workspace.Plan != newPlan)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} plan changed from {OldPlan} to {NewPlan}",
|
||||
workspace.Id, workspace.Plan, newPlan);
|
||||
workspace.Plan = newPlan;
|
||||
}
|
||||
}
|
||||
|
||||
workspace.SubscriptionEndsAt = subscription.CurrentPeriodEnd;
|
||||
|
||||
// Handle cancellation at period end
|
||||
if (subscription.CancelAtPeriodEnd)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} subscription will cancel at {EndDate}",
|
||||
workspace.Id, subscription.CurrentPeriodEnd);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task HandleSubscriptionDeletedAsync(Subscription subscription, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.StripeSubscriptionId == subscription.Id, ct);
|
||||
|
||||
if (workspace == null)
|
||||
{
|
||||
_logger.LogWarning("No workspace found for deleted subscription: {SubscriptionId}", subscription.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Workspace {WorkspaceId} subscription deleted, downgrading to Free",
|
||||
workspace.Id);
|
||||
|
||||
workspace.Plan = WorkspacePlan.Free;
|
||||
workspace.StripeSubscriptionId = null;
|
||||
workspace.SubscriptionEndsAt = null;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public string GetPriceIdForPlan(WorkspacePlan plan)
|
||||
{
|
||||
return plan switch
|
||||
{
|
||||
WorkspacePlan.Pro => _settings.ProPriceId,
|
||||
WorkspacePlan.Business => _settings.BusinessPriceId,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public WorkspacePlan GetPlanForPriceId(string priceId)
|
||||
{
|
||||
if (priceId == _settings.ProPriceId)
|
||||
return WorkspacePlan.Pro;
|
||||
if (priceId == _settings.BusinessPriceId)
|
||||
return WorkspacePlan.Business;
|
||||
return WorkspacePlan.Free;
|
||||
}
|
||||
}
|
||||
9
src/api/Features/Billing/Settings/StripeSettings.cs
Normal file
9
src/api/Features/Billing/Settings/StripeSettings.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace api.Features.Billing.Settings;
|
||||
|
||||
public class StripeSettings
|
||||
{
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
public string WebhookSecret { get; set; } = string.Empty;
|
||||
public string ProPriceId { get; set; } = string.Empty;
|
||||
public string BusinessPriceId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Domains.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
@@ -28,7 +29,7 @@ public class AddDomainValidator : Validator<AddDomainRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class AddDomainEndpoint(AppDbContext db)
|
||||
public class AddDomainEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<AddDomainRequest, DomainResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -50,6 +51,16 @@ public class AddDomainEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateDomainAsync(req.WorkspaceId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Domain limit reached. Please upgrade your plan to add more custom domains."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize hostname (lowercase, no trailing dots)
|
||||
var hostname = req.Hostname.ToLowerInvariant().TrimEnd('.');
|
||||
|
||||
|
||||
60
src/api/Features/Email/Services/ConsoleEmailService.cs
Normal file
60
src/api/Features/Email/Services/ConsoleEmailService.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using api.Features.Email.Templates;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace api.Features.Email.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Development email service that logs emails to console instead of sending them.
|
||||
/// Useful for testing without a real SMTP server.
|
||||
/// </summary>
|
||||
public class ConsoleEmailService : IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings;
|
||||
private readonly ILogger<ConsoleEmailService> _logger;
|
||||
|
||||
public ConsoleEmailService(IOptions<EmailSettings> settings, ILogger<ConsoleEmailService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
||||
{
|
||||
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
|
||||
var (subject, _, textBody) = EmailTemplates.PasswordReset(resetUrl);
|
||||
|
||||
LogEmail(toEmail, subject, textBody, resetUrl);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
|
||||
{
|
||||
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
||||
var (subject, _, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
||||
|
||||
LogEmail(toEmail, subject, textBody, verifyUrl);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
|
||||
{
|
||||
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
|
||||
var (subject, _, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
|
||||
|
||||
LogEmail(toEmail, subject, textBody, dashboardUrl);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void LogEmail(string toEmail, string subject, string body, string actionUrl)
|
||||
{
|
||||
_logger.LogInformation($"""
|
||||
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ EMAIL (Console Mode) ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
To: {toEmail}
|
||||
Subject: {subject}
|
||||
Action URL: {actionUrl}
|
||||
""");
|
||||
}
|
||||
}
|
||||
36
src/api/Features/Email/Services/IEmailService.cs
Normal file
36
src/api/Features/Email/Services/IEmailService.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace api.Features.Email.Services;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default);
|
||||
Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default);
|
||||
Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class EmailSettings
|
||||
{
|
||||
public string Provider { get; set; } = "smtp"; // smtp, sendgrid, ses
|
||||
public string FromEmail { get; set; } = "noreply@trakqr.com";
|
||||
public string FromName { get; set; } = "TrakQR";
|
||||
public string BaseUrl { get; set; } = "https://trakqr.com";
|
||||
|
||||
// SMTP settings
|
||||
public SmtpSettings? Smtp { get; set; }
|
||||
|
||||
// SendGrid settings
|
||||
public SendGridSettings? SendGrid { get; set; }
|
||||
}
|
||||
|
||||
public class SmtpSettings
|
||||
{
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 587;
|
||||
public bool UseSsl { get; set; } = true;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
|
||||
public class SendGridSettings
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
92
src/api/Features/Email/Services/SmtpEmailService.cs
Normal file
92
src/api/Features/Email/Services/SmtpEmailService.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using api.Features.Email.Templates;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace api.Features.Email.Services;
|
||||
|
||||
public class SmtpEmailService : IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings;
|
||||
private readonly ILogger<SmtpEmailService> _logger;
|
||||
|
||||
public SmtpEmailService(IOptions<EmailSettings> settings, ILogger<SmtpEmailService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendPasswordResetEmailAsync(string toEmail, string resetToken, CancellationToken ct = default)
|
||||
{
|
||||
var resetUrl = $"{_settings.BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.PasswordReset(resetUrl);
|
||||
|
||||
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
|
||||
_logger.LogInformation("Password reset email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
public async Task SendEmailVerificationAsync(string toEmail, string verificationToken, CancellationToken ct = default)
|
||||
{
|
||||
var verifyUrl = $"{_settings.BaseUrl}/verify-email?token={Uri.EscapeDataString(verificationToken)}";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.EmailVerification(verifyUrl);
|
||||
|
||||
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
|
||||
_logger.LogInformation("Verification email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
public async Task SendWelcomeEmailAsync(string toEmail, string userName, CancellationToken ct = default)
|
||||
{
|
||||
var dashboardUrl = $"{_settings.BaseUrl}/dashboard";
|
||||
var (subject, htmlBody, textBody) = EmailTemplates.Welcome(userName, dashboardUrl);
|
||||
|
||||
await SendEmailAsync(toEmail, subject, htmlBody, textBody, ct);
|
||||
_logger.LogInformation("Welcome email sent to {Email}", toEmail);
|
||||
}
|
||||
|
||||
private async Task SendEmailAsync(string toEmail, string subject, string htmlBody, string textBody, CancellationToken ct)
|
||||
{
|
||||
if (_settings.Smtp == null)
|
||||
{
|
||||
_logger.LogWarning("SMTP settings not configured. Email not sent to {Email}", toEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var message = new MailMessage
|
||||
{
|
||||
From = new MailAddress(_settings.FromEmail, _settings.FromName),
|
||||
Subject = subject,
|
||||
IsBodyHtml = true,
|
||||
Body = htmlBody
|
||||
};
|
||||
|
||||
message.To.Add(new MailAddress(toEmail));
|
||||
|
||||
// Add plain text alternative
|
||||
var plainTextView = AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain");
|
||||
var htmlView = AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html");
|
||||
message.AlternateViews.Add(plainTextView);
|
||||
message.AlternateViews.Add(htmlView);
|
||||
|
||||
using var client = new SmtpClient(_settings.Smtp.Host, _settings.Smtp.Port)
|
||||
{
|
||||
EnableSsl = _settings.Smtp.UseSsl,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.Smtp.Username))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(_settings.Smtp.Username, _settings.Smtp.Password);
|
||||
}
|
||||
|
||||
await client.SendMailAsync(message, ct);
|
||||
_logger.LogDebug("Email sent successfully to {Email}", toEmail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send email to {Email}", toEmail);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
221
src/api/Features/Email/Templates/EmailTemplates.cs
Normal file
221
src/api/Features/Email/Templates/EmailTemplates.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
namespace api.Features.Email.Templates;
|
||||
|
||||
public static class EmailTemplates
|
||||
{
|
||||
public static (string Subject, string HtmlBody, string TextBody) PasswordReset(string resetUrl)
|
||||
{
|
||||
var subject = "Reset your TrakQR password";
|
||||
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Reset Your Password</title>
|
||||
</head>
|
||||
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
|
||||
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
|
||||
<tr>
|
||||
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
|
||||
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 20px;"">
|
||||
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Reset your password</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">We received a request to reset your password. Click the button below to choose a new password.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<a href=""{resetUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Reset Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">This link will expire in 1 hour.</p>
|
||||
<p style=""margin: 10px 0 0 0;"">If you didn't request this, you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
|
||||
<p style=""margin: 0;"">© TrakQR. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var textBody = $@"Reset Your Password
|
||||
|
||||
We received a request to reset your TrakQR password.
|
||||
|
||||
Click the link below to reset your password:
|
||||
{resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
- The TrakQR Team";
|
||||
|
||||
return (subject, htmlBody, textBody);
|
||||
}
|
||||
|
||||
public static (string Subject, string HtmlBody, string TextBody) EmailVerification(string verifyUrl)
|
||||
{
|
||||
var subject = "Verify your TrakQR email";
|
||||
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Verify Your Email</title>
|
||||
</head>
|
||||
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
|
||||
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
|
||||
<tr>
|
||||
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
|
||||
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 20px;"">
|
||||
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Verify your email</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">Thanks for signing up! Please verify your email address to get started with TrakQR.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<a href=""{verifyUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Verify Email</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; color: #999999; font-size: 14px; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">This link will expire in 24 hours.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
|
||||
<p style=""margin: 0;"">© TrakQR. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var textBody = $@"Verify Your Email
|
||||
|
||||
Thanks for signing up for TrakQR!
|
||||
|
||||
Please verify your email address by clicking the link below:
|
||||
{verifyUrl}
|
||||
|
||||
This link will expire in 24 hours.
|
||||
|
||||
- The TrakQR Team";
|
||||
|
||||
return (subject, htmlBody, textBody);
|
||||
}
|
||||
|
||||
public static (string Subject, string HtmlBody, string TextBody) Welcome(string userName, string dashboardUrl)
|
||||
{
|
||||
var subject = "Welcome to TrakQR!";
|
||||
|
||||
var htmlBody = $@"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=""utf-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Welcome to TrakQR</title>
|
||||
</head>
|
||||
<body style=""margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;"">
|
||||
<table role=""presentation"" width=""100%"" cellspacing=""0"" cellpadding=""0"" style=""max-width: 600px; margin: 0 auto; padding: 40px 20px;"">
|
||||
<tr>
|
||||
<td style=""background-color: #ffffff; border-radius: 16px; padding: 40px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);"">
|
||||
<table width=""100%"" cellspacing=""0"" cellpadding=""0"">
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<span style=""display: inline-block; background: #1a1a1a; color: #fff4ec; padding: 12px 16px; border-radius: 12px; font-weight: bold; font-size: 18px;"">TQ</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 20px;"">
|
||||
<h1 style=""margin: 0; font-size: 24px; color: #1a1a1a;"">Welcome to TrakQR!</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px; color: #666666; line-height: 1.6;"">
|
||||
<p style=""margin: 0;"">Hi {userName},</p>
|
||||
<p style=""margin: 10px 0 0 0;"">You're all set! Start creating short links and beautiful QR codes with powerful analytics.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-bottom: 30px;"">
|
||||
<a href=""{dashboardUrl}"" style=""display: inline-block; background: #ff6a3d; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 12px; font-weight: 600; font-size: 16px;"">Go to Dashboard</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""padding: 20px; background: #f9f9f9; border-radius: 12px;"">
|
||||
<h3 style=""margin: 0 0 15px 0; font-size: 16px; color: #1a1a1a;"">Get started:</h3>
|
||||
<ul style=""margin: 0; padding-left: 20px; color: #666666; line-height: 1.8;"">
|
||||
<li>Create your first short link</li>
|
||||
<li>Design a custom QR code</li>
|
||||
<li>Track clicks and scans in real-time</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""text-align: center; padding-top: 30px; color: #999999; font-size: 12px;"">
|
||||
<p style=""margin: 0;"">© TrakQR. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
var textBody = $@"Welcome to TrakQR!
|
||||
|
||||
Hi {userName},
|
||||
|
||||
You're all set! Start creating short links and beautiful QR codes with powerful analytics.
|
||||
|
||||
Get started:
|
||||
- Create your first short link
|
||||
- Design a custom QR code
|
||||
- Track clicks and scans in real-time
|
||||
|
||||
Go to your dashboard: {dashboardUrl}
|
||||
|
||||
- The TrakQR Team";
|
||||
|
||||
return (subject, htmlBody, textBody);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public interface IEventTrackingService
|
||||
Task TrackScanAsync(Guid workspaceId, Guid shortLinkId, Guid qrCodeId, HttpContext context);
|
||||
}
|
||||
|
||||
public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<EventTrackingService> logger)
|
||||
public class EventTrackingService(IServiceScopeFactory scopeFactory, IGeoIpService geoIpService, ILogger<EventTrackingService> logger)
|
||||
: IEventTrackingService
|
||||
{
|
||||
// Dedupe window - same visitor clicking same link within this window counts as one
|
||||
@@ -72,6 +72,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<Eve
|
||||
|
||||
var ipHash = HashIpAddress(ipAddress);
|
||||
var deviceType = ParseDeviceType(userAgent);
|
||||
var countryCode = geoIpService.GetCountryCode(ipAddress);
|
||||
var dedupeKey = GenerateDedupeKey(ipHash, shortLinkId, qrCodeId);
|
||||
|
||||
// Check for duplicate within the dedupe window
|
||||
@@ -95,7 +96,7 @@ public class EventTrackingService(IServiceScopeFactory scopeFactory, ILogger<Eve
|
||||
IpHash = ipHash,
|
||||
UserAgent = TruncateString(userAgent, 512),
|
||||
Referrer = TruncateString(referrer, 2048),
|
||||
CountryCode = null, // TODO: GeoIP lookup
|
||||
CountryCode = countryCode,
|
||||
DeviceType = deviceType,
|
||||
DedupeKey = dedupeKey
|
||||
};
|
||||
|
||||
95
src/api/Features/Events/Services/GeoIpService.cs
Normal file
95
src/api/Features/Events/Services/GeoIpService.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Net;
|
||||
using MaxMind.GeoIP2;
|
||||
|
||||
namespace api.Features.Events.Services;
|
||||
|
||||
public interface IGeoIpService
|
||||
{
|
||||
string? GetCountryCode(string ipAddress);
|
||||
}
|
||||
|
||||
public class GeoIpService : IGeoIpService, IDisposable
|
||||
{
|
||||
private readonly DatabaseReader? _reader;
|
||||
private readonly ILogger<GeoIpService> _logger;
|
||||
|
||||
public GeoIpService(IConfiguration configuration, ILogger<GeoIpService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var dbPath = configuration["GeoIP:DatabasePath"];
|
||||
|
||||
if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
_reader = new DatabaseReader(dbPath);
|
||||
_logger.LogInformation("GeoIP database loaded from {Path}", dbPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load GeoIP database from {Path}", dbPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("GeoIP database not configured or not found. Country detection disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetCountryCode(string ipAddress)
|
||||
{
|
||||
if (_reader == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Handle localhost and private IPs
|
||||
if (ipAddress == "127.0.0.1" || ipAddress == "::1" || IsPrivateIp(ipAddress))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!IPAddress.TryParse(ipAddress, out var ip))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_reader.TryCountry(ip, out var response))
|
||||
{
|
||||
return response?.Country?.IsoCode;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to lookup country for IP {IP}", ipAddress);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsPrivateIp(string ipAddress)
|
||||
{
|
||||
if (!IPAddress.TryParse(ipAddress, out var ip))
|
||||
return false;
|
||||
|
||||
var bytes = ip.GetAddressBytes();
|
||||
|
||||
// Check for IPv4 private ranges
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
// 10.0.0.0/8
|
||||
if (bytes[0] == 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_reader?.Dispose();
|
||||
}
|
||||
}
|
||||
12
src/api/Features/Links/Common/LinkDto.cs
Normal file
12
src/api/Features/Links/Common/LinkDto.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace api.Features.Links.Endpoints;
|
||||
|
||||
public class LinkDto
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public required string DestinationUrl { get; set; }
|
||||
public required string? Title { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public int ClickCount { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
};
|
||||
@@ -12,7 +12,8 @@ public record LinkResponse(
|
||||
DateTime? ExpiresAt,
|
||||
bool HasPassword,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
DateTime UpdatedAt,
|
||||
DateTime? DeletedAt = null
|
||||
);
|
||||
|
||||
public record LinkListResponse(
|
||||
|
||||
177
src/api/Features/Links/Endpoints/BulkCreateLinksEndpoint.cs
Normal file
177
src/api/Features/Links/Endpoints/BulkCreateLinksEndpoint.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
|
||||
public class BulkCreateLinksRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required List<BulkLinkItem> Links { get; set; }
|
||||
}
|
||||
|
||||
public class BulkLinkItem
|
||||
{
|
||||
public required string DestinationUrl { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Slug { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateLinksResponse
|
||||
{
|
||||
public required List<LinkDto> Created { get; set; }
|
||||
public required List<BulkLinkError> Errors { get; set; }
|
||||
}
|
||||
|
||||
public class BulkLinkError
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public required string Error { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateLinksEndpoint(AppDbContext db)
|
||||
: Endpoint<BulkCreateLinksRequest, BulkCreateLinksResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/links/bulk");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(BulkCreateLinksRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Verify workspace ownership
|
||||
var workspace = await db.Workspaces
|
||||
.FirstOrDefaultAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct);
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit bulk creation to 100 links at a time
|
||||
if (req.Links.Count > 100)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Maximum 100 links per request"), 400, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var created = new List<LinkDto>();
|
||||
var errors = new List<BulkLinkError>();
|
||||
|
||||
// Check for plan limits
|
||||
var currentLinkCount = await db.ShortLinks.CountAsync(l => l.WorkspaceId == req.WorkspaceId, ct);
|
||||
var linkLimit = GetPlanLinkLimit(workspace.Plan);
|
||||
|
||||
for (int i = 0; i < req.Links.Count; i++)
|
||||
{
|
||||
var item = req.Links[i];
|
||||
|
||||
// Validate URL
|
||||
if (!Uri.TryCreate(item.DestinationUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
errors.Add(new BulkLinkError
|
||||
{
|
||||
Index = i,
|
||||
Url = item.DestinationUrl,
|
||||
Error = "Invalid URL"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (linkLimit.HasValue && currentLinkCount + created.Count >= linkLimit.Value)
|
||||
{
|
||||
errors.Add(new BulkLinkError
|
||||
{
|
||||
Index = i,
|
||||
Url = item.DestinationUrl,
|
||||
Error = "Plan link limit reached"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate or validate slug
|
||||
var slug = item.Slug;
|
||||
if (string.IsNullOrWhiteSpace(slug))
|
||||
{
|
||||
slug = GenerateSlug();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if slug is taken
|
||||
var slugTaken = await db.ShortLinks.AnyAsync(l => l.Slug == slug, ct);
|
||||
if (slugTaken)
|
||||
{
|
||||
errors.Add(new BulkLinkError
|
||||
{
|
||||
Index = i,
|
||||
Url = item.DestinationUrl,
|
||||
Error = $"Slug '{slug}' is already taken"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var link = new ShortLink
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Slug = slug,
|
||||
DestinationUrl = item.DestinationUrl,
|
||||
Title = item.Title,
|
||||
Status = ShortLinkStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
db.ShortLinks.Add(link);
|
||||
|
||||
created.Add(new LinkDto
|
||||
{
|
||||
Id = link.Id,
|
||||
Slug = link.Slug,
|
||||
DestinationUrl = link.DestinationUrl,
|
||||
Title = link?.Title,
|
||||
Status = link.Status.ToString(),
|
||||
ClickCount = 0,
|
||||
CreatedAt = link.CreatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new BulkCreateLinksResponse
|
||||
{
|
||||
Created = created,
|
||||
Errors = errors
|
||||
};
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
|
||||
}
|
||||
|
||||
private static string GenerateSlug()
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var random = new Random();
|
||||
return new string(Enumerable.Repeat(chars, 7).Select(s => s[random.Next(s.Length)]).ToArray());
|
||||
}
|
||||
|
||||
private static int? GetPlanLinkLimit(WorkspacePlan? plan)
|
||||
{
|
||||
return plan switch
|
||||
{
|
||||
WorkspacePlan.Business => null, // Unlimited
|
||||
WorkspacePlan.Pro => 10000,
|
||||
_ => 100 // Free plan
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
@@ -43,7 +44,7 @@ public class CreateLinkValidator : Validator<CreateLinkRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateLinkEndpoint(AppDbContext db)
|
||||
public class CreateLinkEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<CreateLinkRequest, LinkResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -65,6 +66,16 @@ public class CreateLinkEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateLinkAsync(req.WorkspaceId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Link limit reached. Please upgrade your plan to create more links."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify project belongs to workspace if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
|
||||
@@ -26,7 +26,11 @@ public class DeleteLinkEndpoint(AppDbContext db)
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Include(l => l.Workspace)
|
||||
.FirstOrDefaultAsync(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId, ct);
|
||||
.FirstOrDefaultAsync(l =>
|
||||
l.Id == req.Id &&
|
||||
l.WorkspaceId == req.WorkspaceId &&
|
||||
l.Workspace.OwnerUserId == userId &&
|
||||
l.DeletedAt == null, ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
@@ -34,7 +38,8 @@ public class DeleteLinkEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
db.ShortLinks.Remove(link);
|
||||
// Soft delete
|
||||
link.DeletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Link deleted"), 200, cancellation: ct);
|
||||
|
||||
@@ -26,7 +26,7 @@ public class GetLinkEndpoint(AppDbContext db)
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId)
|
||||
.Where(l => l.Id == req.Id && l.WorkspaceId == req.WorkspaceId && l.Workspace.OwnerUserId == userId && l.DeletedAt == null)
|
||||
.Select(l => new LinkResponse(
|
||||
l.Id,
|
||||
l.WorkspaceId,
|
||||
@@ -39,7 +39,8 @@ public class GetLinkEndpoint(AppDbContext db)
|
||||
l.ExpiresAt,
|
||||
l.PasswordHash != null,
|
||||
l.CreatedAt,
|
||||
l.UpdatedAt
|
||||
l.UpdatedAt,
|
||||
l.DeletedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ListLinksRequest
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool IncludeDeleted { get; set; } = false;
|
||||
}
|
||||
|
||||
public class ListLinksEndpoint(AppDbContext db)
|
||||
@@ -39,6 +40,12 @@ public class ListLinksEndpoint(AppDbContext db)
|
||||
var query = db.ShortLinks
|
||||
.Where(l => l.WorkspaceId == req.WorkspaceId);
|
||||
|
||||
// Filter by deleted status (exclude soft-deleted by default)
|
||||
if (!req.IncludeDeleted)
|
||||
{
|
||||
query = query.Where(l => l.DeletedAt == null);
|
||||
}
|
||||
|
||||
// Filter by project if specified
|
||||
if (req.ProjectId.HasValue)
|
||||
{
|
||||
@@ -65,7 +72,8 @@ public class ListLinksEndpoint(AppDbContext db)
|
||||
l.ExpiresAt,
|
||||
l.PasswordHash != null,
|
||||
l.CreatedAt,
|
||||
l.UpdatedAt
|
||||
l.UpdatedAt,
|
||||
l.DeletedAt
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
|
||||
65
src/api/Features/Links/Endpoints/RestoreLinkEndpoint.cs
Normal file
65
src/api/Features/Links/Endpoints/RestoreLinkEndpoint.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Links.Common;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Links.Endpoints;
|
||||
|
||||
public class RestoreLinkRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
public class RestoreLinkEndpoint(AppDbContext db)
|
||||
: Endpoint<RestoreLinkRequest, LinkResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/workspaces/{WorkspaceId}/links/{Id}/restore");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RestoreLinkRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var link = await db.ShortLinks
|
||||
.Include(l => l.Workspace)
|
||||
.FirstOrDefaultAsync(l =>
|
||||
l.Id == req.Id &&
|
||||
l.WorkspaceId == req.WorkspaceId &&
|
||||
l.Workspace.OwnerUserId == userId &&
|
||||
l.DeletedAt != null, ct);
|
||||
|
||||
if (link is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Deleted link not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the link
|
||||
link.DeletedAt = null;
|
||||
link.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new LinkResponse(
|
||||
link.Id,
|
||||
link.WorkspaceId,
|
||||
link.ProjectId,
|
||||
link.DomainId,
|
||||
link.Slug,
|
||||
link.DestinationUrl,
|
||||
link.Title,
|
||||
link.Status.ToString(),
|
||||
link.ExpiresAt,
|
||||
link.PasswordHash != null,
|
||||
link.CreatedAt,
|
||||
link.UpdatedAt,
|
||||
link.DeletedAt
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
}
|
||||
}
|
||||
94
src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs
Normal file
94
src/api/Features/Plans/Endpoints/GetUsageEndpoint.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Security.Claims;
|
||||
using api.Features.Plans.Services;
|
||||
using FastEndpoints;
|
||||
|
||||
namespace api.Features.Plans.Endpoints;
|
||||
|
||||
public class GetUsageRequest
|
||||
{
|
||||
public Guid? WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
public record UsageResponse(
|
||||
int Workspaces,
|
||||
int Links,
|
||||
int QRCodes,
|
||||
int Domains,
|
||||
int EventsThisMonth,
|
||||
string Plan,
|
||||
LimitsResponse Limits
|
||||
);
|
||||
|
||||
public record LimitsResponse(
|
||||
int MaxWorkspaces,
|
||||
int MaxLinks,
|
||||
int MaxQRCodes,
|
||||
int MaxDomains,
|
||||
int MaxEventsPerMonth,
|
||||
bool HasCustomDomains,
|
||||
bool HasPasswordProtection
|
||||
);
|
||||
|
||||
public class GetUsageEndpoint(IPlanLimitsService planLimits)
|
||||
: Endpoint<GetUsageRequest, UsageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/usage");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetUsageRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
if (req.WorkspaceId.HasValue)
|
||||
{
|
||||
var wsUsage = await planLimits.GetWorkspaceUsageAsync(req.WorkspaceId.Value, ct);
|
||||
|
||||
var response = new UsageResponse(
|
||||
Workspaces: 1,
|
||||
Links: wsUsage.Links,
|
||||
QRCodes: wsUsage.QRCodes,
|
||||
Domains: wsUsage.Domains,
|
||||
EventsThisMonth: wsUsage.EventsThisMonth,
|
||||
Plan: wsUsage.Plan.ToString(),
|
||||
Limits: new LimitsResponse(
|
||||
MaxWorkspaces: wsUsage.Limits.MaxWorkspaces,
|
||||
MaxLinks: wsUsage.Limits.MaxLinksPerWorkspace,
|
||||
MaxQRCodes: wsUsage.Limits.MaxQRCodesPerWorkspace,
|
||||
MaxDomains: wsUsage.Limits.MaxDomainsPerWorkspace,
|
||||
MaxEventsPerMonth: wsUsage.Limits.MaxEventsPerMonth,
|
||||
HasCustomDomains: wsUsage.Limits.HasCustomDomains,
|
||||
HasPasswordProtection: wsUsage.Limits.HasPasswordProtection
|
||||
)
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var usage = await planLimits.GetUsageAsync(userId, ct);
|
||||
var limits = planLimits.GetLimits(usage.HighestPlan);
|
||||
|
||||
var response = new UsageResponse(
|
||||
Workspaces: usage.TotalWorkspaces,
|
||||
Links: usage.TotalLinks,
|
||||
QRCodes: usage.TotalQRCodes,
|
||||
Domains: usage.TotalDomains,
|
||||
EventsThisMonth: usage.EventsThisMonth,
|
||||
Plan: usage.HighestPlan.ToString(),
|
||||
Limits: new LimitsResponse(
|
||||
MaxWorkspaces: limits.MaxWorkspaces,
|
||||
MaxLinks: limits.MaxLinksPerWorkspace,
|
||||
MaxQRCodes: limits.MaxQRCodesPerWorkspace,
|
||||
MaxDomains: limits.MaxDomainsPerWorkspace,
|
||||
MaxEventsPerMonth: limits.MaxEventsPerMonth,
|
||||
HasCustomDomains: limits.HasCustomDomains,
|
||||
HasPasswordProtection: limits.HasPasswordProtection
|
||||
)
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/api/Features/Plans/Services/PlanLimitsService.cs
Normal file
190
src/api/Features/Plans/Services/PlanLimitsService.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using api.Data;
|
||||
using api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.Plans.Services;
|
||||
|
||||
public interface IPlanLimitsService
|
||||
{
|
||||
PlanLimits GetLimits(WorkspacePlan plan);
|
||||
Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public record PlanLimits(
|
||||
int MaxWorkspaces,
|
||||
int MaxLinksPerWorkspace,
|
||||
int MaxQRCodesPerWorkspace,
|
||||
int MaxDomainsPerWorkspace,
|
||||
int MaxEventsPerMonth,
|
||||
bool HasCustomDomains,
|
||||
bool HasPasswordProtection,
|
||||
bool HasAnalytics
|
||||
);
|
||||
|
||||
public record UsageStats(
|
||||
int TotalWorkspaces,
|
||||
int TotalLinks,
|
||||
int TotalQRCodes,
|
||||
int TotalDomains,
|
||||
int EventsThisMonth,
|
||||
WorkspacePlan HighestPlan
|
||||
);
|
||||
|
||||
public record WorkspaceUsageStats(
|
||||
Guid WorkspaceId,
|
||||
WorkspacePlan Plan,
|
||||
int Links,
|
||||
int QRCodes,
|
||||
int Domains,
|
||||
int EventsThisMonth,
|
||||
PlanLimits Limits
|
||||
);
|
||||
|
||||
public class PlanLimitsService(IServiceScopeFactory scopeFactory) : IPlanLimitsService
|
||||
{
|
||||
private static readonly Dictionary<WorkspacePlan, PlanLimits> PlanConfigs = new()
|
||||
{
|
||||
[WorkspacePlan.Free] = new PlanLimits(
|
||||
MaxWorkspaces: 1,
|
||||
MaxLinksPerWorkspace: 50,
|
||||
MaxQRCodesPerWorkspace: 25,
|
||||
MaxDomainsPerWorkspace: 0,
|
||||
MaxEventsPerMonth: 10_000,
|
||||
HasCustomDomains: false,
|
||||
HasPasswordProtection: false,
|
||||
HasAnalytics: true
|
||||
),
|
||||
[WorkspacePlan.Pro] = new PlanLimits(
|
||||
MaxWorkspaces: 5,
|
||||
MaxLinksPerWorkspace: 5_000,
|
||||
MaxQRCodesPerWorkspace: 1_000,
|
||||
MaxDomainsPerWorkspace: 3,
|
||||
MaxEventsPerMonth: 100_000,
|
||||
HasCustomDomains: true,
|
||||
HasPasswordProtection: true,
|
||||
HasAnalytics: true
|
||||
),
|
||||
[WorkspacePlan.Business] = new PlanLimits(
|
||||
MaxWorkspaces: int.MaxValue,
|
||||
MaxLinksPerWorkspace: int.MaxValue,
|
||||
MaxQRCodesPerWorkspace: int.MaxValue,
|
||||
MaxDomainsPerWorkspace: int.MaxValue,
|
||||
MaxEventsPerMonth: int.MaxValue,
|
||||
HasCustomDomains: true,
|
||||
HasPasswordProtection: true,
|
||||
HasAnalytics: true
|
||||
)
|
||||
};
|
||||
|
||||
public PlanLimits GetLimits(WorkspacePlan plan) => PlanConfigs[plan];
|
||||
|
||||
public async Task<UsageStats> GetUsageAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspaces = await db.Workspaces
|
||||
.Where(w => w.OwnerUserId == userId)
|
||||
.Select(w => new { w.Id, w.Plan })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var workspaceIds = workspaces.Select(w => w.Id).ToList();
|
||||
|
||||
var totalLinks = await db.ShortLinks
|
||||
.CountAsync(l => workspaceIds.Contains(l.WorkspaceId), ct);
|
||||
|
||||
var totalQRCodes = await db.QrCodeDesigns
|
||||
.CountAsync(q => workspaceIds.Contains(q.WorkspaceId), ct);
|
||||
|
||||
var totalDomains = await db.Domains
|
||||
.CountAsync(d => workspaceIds.Contains(d.WorkspaceId), ct);
|
||||
|
||||
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var eventsThisMonth = await db.Events
|
||||
.CountAsync(e => workspaceIds.Contains(e.WorkspaceId) && e.Timestamp >= monthStart, ct);
|
||||
|
||||
var highestPlan = workspaces.Any()
|
||||
? workspaces.Max(w => w.Plan)
|
||||
: WorkspacePlan.Free;
|
||||
|
||||
return new UsageStats(
|
||||
TotalWorkspaces: workspaces.Count,
|
||||
TotalLinks: totalLinks,
|
||||
TotalQRCodes: totalQRCodes,
|
||||
TotalDomains: totalDomains,
|
||||
EventsThisMonth: eventsThisMonth,
|
||||
HighestPlan: highestPlan
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<WorkspaceUsageStats> GetWorkspaceUsageAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var workspace = await db.Workspaces
|
||||
.Where(w => w.Id == workspaceId)
|
||||
.Select(w => new { w.Id, w.Plan })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (workspace == null)
|
||||
throw new KeyNotFoundException("Workspace not found");
|
||||
|
||||
var links = await db.ShortLinks.CountAsync(l => l.WorkspaceId == workspaceId, ct);
|
||||
var qrCodes = await db.QrCodeDesigns.CountAsync(q => q.WorkspaceId == workspaceId, ct);
|
||||
var domains = await db.Domains.CountAsync(d => d.WorkspaceId == workspaceId, ct);
|
||||
|
||||
var monthStart = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var eventsThisMonth = await db.Events
|
||||
.CountAsync(e => e.WorkspaceId == workspaceId && e.Timestamp >= monthStart, ct);
|
||||
|
||||
var limits = GetLimits(workspace.Plan);
|
||||
|
||||
return new WorkspaceUsageStats(
|
||||
WorkspaceId: workspaceId,
|
||||
Plan: workspace.Plan,
|
||||
Links: links,
|
||||
QRCodes: qrCodes,
|
||||
Domains: domains,
|
||||
EventsThisMonth: eventsThisMonth,
|
||||
Limits: limits
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateWorkspaceAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetUsageAsync(userId, ct);
|
||||
var limits = GetLimits(usage.HighestPlan);
|
||||
return usage.TotalWorkspaces < limits.MaxWorkspaces;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateLinkAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.Links < usage.Limits.MaxLinksPerWorkspace;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateQRCodeAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.QRCodes < usage.Limits.MaxQRCodesPerWorkspace;
|
||||
}
|
||||
|
||||
public async Task<bool> CanCreateDomainAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.Domains < usage.Limits.MaxDomainsPerWorkspace;
|
||||
}
|
||||
|
||||
public async Task<bool> CanTrackEventAsync(Guid workspaceId, CancellationToken ct = default)
|
||||
{
|
||||
var usage = await GetWorkspaceUsageAsync(workspaceId, ct);
|
||||
return usage.EventsThisMonth < usage.Limits.MaxEventsPerMonth;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ public record ProjectResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
string? Description,
|
||||
int LinkCount,
|
||||
int QRCodeCount,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public class CreateProjectRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class CreateProjectValidator : Validator<CreateProjectRequest>
|
||||
@@ -52,6 +53,7 @@ public class CreateProjectEndpoint(AppDbContext db)
|
||||
Id = Guid.NewGuid(),
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
Name = req.Name,
|
||||
Description = req.Description,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@@ -62,6 +64,9 @@ public class CreateProjectEndpoint(AppDbContext db)
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
0, // LinkCount - new project has no links
|
||||
0, // QRCodeCount - new project has no QR codes
|
||||
project.CreatedAt
|
||||
);
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ public class GetProjectEndpoint(AppDbContext db)
|
||||
p.Id,
|
||||
p.WorkspaceId,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.ShortLinks.Count(l => l.DeletedAt == null),
|
||||
p.QRCodeDesigns.Count,
|
||||
p.CreatedAt
|
||||
))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
@@ -41,6 +41,9 @@ public class ListProjectsEndpoint(AppDbContext db)
|
||||
p.Id,
|
||||
p.WorkspaceId,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.ShortLinks.Count(l => l.DeletedAt == null),
|
||||
p.QRCodeDesigns.Count,
|
||||
p.CreatedAt
|
||||
))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -13,6 +13,7 @@ public class UpdateProjectRequest
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProjectValidator : Validator<UpdateProjectRequest>
|
||||
@@ -39,6 +40,8 @@ public class UpdateProjectEndpoint(AppDbContext db)
|
||||
|
||||
var project = await db.Projects
|
||||
.Include(p => p.Workspace)
|
||||
.Include(p => p.ShortLinks)
|
||||
.Include(p => p.QRCodeDesigns)
|
||||
.FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (project is null)
|
||||
@@ -48,12 +51,16 @@ public class UpdateProjectEndpoint(AppDbContext db)
|
||||
}
|
||||
|
||||
project.Name = req.Name;
|
||||
project.Description = req.Description;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var response = new ProjectResponse(
|
||||
project.Id,
|
||||
project.WorkspaceId,
|
||||
project.Name,
|
||||
project.Description,
|
||||
project.ShortLinks.Count(l => l.DeletedAt == null),
|
||||
project.QRCodeDesigns.Count,
|
||||
project.CreatedAt
|
||||
);
|
||||
|
||||
|
||||
@@ -35,8 +35,10 @@ public record QRCodeResponse(
|
||||
Guid? ProjectId,
|
||||
Guid? ShortLinkId,
|
||||
string? ShortLinkSlug,
|
||||
string Name,
|
||||
QRCodeStyle Style,
|
||||
Guid? LogoAssetId,
|
||||
string? LogoUrl,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
@@ -15,6 +16,8 @@ public class CreateQRCodeRequest
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? ShortLinkId { get; set; }
|
||||
public Guid? LogoAssetId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public QRCodeStyle? Style { get; set; }
|
||||
}
|
||||
|
||||
@@ -27,7 +30,7 @@ public class CreateQRCodeValidator : Validator<CreateQRCodeRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateQRCodeEndpoint(AppDbContext db)
|
||||
public class CreateQRCodeEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<CreateQRCodeRequest, QRCodeResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -49,6 +52,16 @@ public class CreateQRCodeEndpoint(AppDbContext db)
|
||||
return;
|
||||
}
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateQRCodeAsync(req.WorkspaceId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("QR code limit reached. Please upgrade your plan to create more QR codes."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify short link belongs to workspace
|
||||
string? linkSlug = null;
|
||||
if (req.ShortLinkId.HasValue)
|
||||
@@ -79,7 +92,26 @@ public class CreateQRCodeEndpoint(AppDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify logo asset belongs to workspace if specified
|
||||
string? logoUrl = null;
|
||||
if (req.LogoAssetId.HasValue)
|
||||
{
|
||||
var asset = await db.Assets
|
||||
.Where(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId)
|
||||
.Select(a => new { a.StorageKey })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
logoUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/assets/{asset.StorageKey}";
|
||||
}
|
||||
|
||||
var style = req.Style ?? new QRCodeStyle();
|
||||
var name = req.Name ?? $"QR Code {DateTime.UtcNow:yyyy-MM-dd}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var qrCode = new QRCodeDesign
|
||||
@@ -88,8 +120,9 @@ public class CreateQRCodeEndpoint(AppDbContext db)
|
||||
WorkspaceId = req.WorkspaceId,
|
||||
ProjectId = req.ProjectId,
|
||||
ShortLinkId = req.ShortLinkId,
|
||||
Name = name,
|
||||
StyleJson = JsonSerializer.Serialize(style),
|
||||
LogoAssetId = null,
|
||||
LogoAssetId = req.LogoAssetId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
@@ -103,8 +136,10 @@ public class CreateQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.ProjectId,
|
||||
qrCode.ShortLinkId,
|
||||
linkSlug,
|
||||
qrCode.Name,
|
||||
style,
|
||||
qrCode.LogoAssetId,
|
||||
logoUrl,
|
||||
qrCode.CreatedAt,
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Features.QRCodes.Services;
|
||||
@@ -17,7 +18,7 @@ public class ExportQRCodeRequest
|
||||
public int? Size { get; set; }
|
||||
}
|
||||
|
||||
public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator)
|
||||
public class ExportQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
|
||||
: Endpoint<ExportQRCodeRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -31,6 +32,7 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
@@ -50,25 +52,44 @@ public class ExportQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGen
|
||||
var format = (req.Format ?? "png").ToLowerInvariant();
|
||||
var size = req.Size ?? 512;
|
||||
|
||||
// Build the short link URL
|
||||
// Build the short link URL with QR tracking param
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
|
||||
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}?qr={qrCode.Id}";
|
||||
|
||||
var filename = $"qrcode-{qrCode.ShortLink.Slug}";
|
||||
|
||||
if (format == "svg")
|
||||
// Load logo if available
|
||||
Stream? logoStream = null;
|
||||
if (qrCode.LogoAsset != null)
|
||||
{
|
||||
var svg = qrGenerator.GenerateSvg(linkUrl, style, size);
|
||||
HttpContext.Response.ContentType = "image/svg+xml";
|
||||
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\"";
|
||||
await HttpContext.Response.WriteAsync(svg, ct);
|
||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||
if (logoResult.HasValue)
|
||||
{
|
||||
logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
try
|
||||
{
|
||||
var png = qrGenerator.GeneratePng(linkUrl, style, size);
|
||||
HttpContext.Response.ContentType = "image/png";
|
||||
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
|
||||
await HttpContext.Response.Body.WriteAsync(png, ct);
|
||||
if (format == "svg")
|
||||
{
|
||||
// SVG doesn't support logo overlay currently
|
||||
var svg = qrGenerator.GenerateSvg(linkUrl, style, size);
|
||||
HttpContext.Response.ContentType = "image/svg+xml";
|
||||
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.svg\"";
|
||||
await HttpContext.Response.WriteAsync(svg, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var png = qrGenerator.GeneratePng(linkUrl, style, size, logoStream);
|
||||
HttpContext.Response.ContentType = "image/png";
|
||||
HttpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"{filename}.png\"";
|
||||
await HttpContext.Response.Body.WriteAsync(png, ct);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
logoStream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
138
src/api/Features/QRCodes/Endpoints/GetQRCodeAnalyticsEndpoint.cs
Normal file
138
src/api/Features/QRCodes/Endpoints/GetQRCodeAnalyticsEndpoint.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace api.Features.QRCodes.Endpoints;
|
||||
|
||||
public class GetQRCodeAnalyticsRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string Period { get; set; } = "7d";
|
||||
}
|
||||
|
||||
public record QRCodeAnalyticsSummary(
|
||||
int TotalScans,
|
||||
int UniqueVisitors
|
||||
);
|
||||
|
||||
public record QRCodeTimeSeriesPoint(
|
||||
string Date,
|
||||
int Scans
|
||||
);
|
||||
|
||||
public record QRCodeAnalyticsResponse(
|
||||
Guid QRCodeId,
|
||||
string Name,
|
||||
string? LinkSlug,
|
||||
QRCodeAnalyticsSummary Summary,
|
||||
List<QRCodeTimeSeriesPoint> TimeSeries,
|
||||
Dictionary<string, int> DeviceBreakdown,
|
||||
Dictionary<string, int> ReferrerBreakdown,
|
||||
Dictionary<string, int> CountryBreakdown
|
||||
);
|
||||
|
||||
public class GetQRCodeAnalyticsEndpoint(AppDbContext db)
|
||||
: Endpoint<GetQRCodeAnalyticsRequest, QRCodeAnalyticsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workspaces/{WorkspaceId}/qrcodes/{Id}/analytics");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(GetQRCodeAnalyticsRequest req, CancellationToken ct)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (qrCode == null)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("QR code not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var startDate = GetStartDate(req.Period);
|
||||
|
||||
var events = await db.Events
|
||||
.Where(e => e.QRCodeId == req.Id && e.Type == EventType.Scan && e.Timestamp >= startDate)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var summary = new QRCodeAnalyticsSummary(
|
||||
TotalScans: events.Count,
|
||||
UniqueVisitors: events.Select(e => e.IpHash).Distinct().Count()
|
||||
);
|
||||
|
||||
var timeSeries = events
|
||||
.GroupBy(e => e.Timestamp.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new QRCodeTimeSeriesPoint(
|
||||
Date: g.Key.ToString("yyyy-MM-dd"),
|
||||
Scans: g.Count()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
var deviceBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.DeviceType))
|
||||
.GroupBy(e => e.DeviceType!)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var referrerBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.Referrer))
|
||||
.GroupBy(e => GetReferrerDomain(e.Referrer!))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var countryBreakdown = events
|
||||
.Where(e => !string.IsNullOrEmpty(e.CountryCode))
|
||||
.GroupBy(e => e.CountryCode!)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(10)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var response = new QRCodeAnalyticsResponse(
|
||||
QRCodeId: qrCode.Id,
|
||||
Name: qrCode.Name,
|
||||
LinkSlug: qrCode.ShortLink?.Slug,
|
||||
Summary: summary,
|
||||
TimeSeries: timeSeries,
|
||||
DeviceBreakdown: deviceBreakdown,
|
||||
ReferrerBreakdown: referrerBreakdown,
|
||||
CountryBreakdown: countryBreakdown
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, cancellation: ct);
|
||||
}
|
||||
|
||||
private static DateTime GetStartDate(string period)
|
||||
{
|
||||
return period switch
|
||||
{
|
||||
"24h" => DateTime.UtcNow.AddHours(-24),
|
||||
"7d" => DateTime.UtcNow.AddDays(-7),
|
||||
"30d" => DateTime.UtcNow.AddDays(-30),
|
||||
"all" => DateTime.MinValue,
|
||||
_ => DateTime.UtcNow.AddDays(-7)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetReferrerDomain(string referrer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(referrer);
|
||||
return uri.Host;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return referrer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class GetQRCodeEndpoint(AppDbContext db)
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
@@ -38,6 +39,7 @@ public class GetQRCodeEndpoint(AppDbContext db)
|
||||
}
|
||||
|
||||
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
|
||||
var response = new QRCodeResponse(
|
||||
qrCode.Id,
|
||||
@@ -45,8 +47,10 @@ public class GetQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.ProjectId,
|
||||
qrCode.ShortLinkId,
|
||||
qrCode.ShortLink?.Slug,
|
||||
qrCode.Name,
|
||||
style,
|
||||
qrCode.LogoAssetId,
|
||||
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
|
||||
qrCode.CreatedAt,
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
@@ -52,9 +52,12 @@ public class ListQRCodesEndpoint(AppDbContext db)
|
||||
|
||||
var qrCodes = await query
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.OrderByDescending(q => q.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
|
||||
var response = new QRCodeListResponse(
|
||||
qrCodes.Select(q => new QRCodeResponse(
|
||||
q.Id,
|
||||
@@ -62,8 +65,10 @@ public class ListQRCodesEndpoint(AppDbContext db)
|
||||
q.ProjectId,
|
||||
q.ShortLinkId,
|
||||
q.ShortLink?.Slug,
|
||||
q.Name,
|
||||
JsonSerializer.Deserialize<QRCodeStyle>(q.StyleJson) ?? new QRCodeStyle(),
|
||||
q.LogoAssetId,
|
||||
q.LogoAsset != null ? $"{baseUrl}/assets/{q.LogoAsset.StorageKey}" : null,
|
||||
q.CreatedAt,
|
||||
q.UpdatedAt
|
||||
))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using api.Data;
|
||||
using api.Features.Assets.Services;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.QRCodes.Common;
|
||||
using api.Features.QRCodes.Services;
|
||||
@@ -16,7 +17,7 @@ public class PreviewQRCodeRequest
|
||||
public int? Size { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGenerator)
|
||||
public class PreviewQRCodeEndpoint(AppDbContext db, IQrCodeGeneratorService qrGenerator, IAssetStorageService assetStorage)
|
||||
: Endpoint<PreviewQRCodeRequest, QRCodePreviewResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -30,6 +31,7 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
|
||||
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.Where(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
@@ -53,15 +55,33 @@ public class PreviewQRCodeEndpoint(AppDbContext db, IQRCodeGeneratorService qrGe
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
var linkUrl = $"{baseUrl}/{qrCode.ShortLink.Slug}";
|
||||
|
||||
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size);
|
||||
// Load logo if available
|
||||
Stream? logoStream = null;
|
||||
if (qrCode.LogoAsset != null)
|
||||
{
|
||||
var logoResult = await assetStorage.GetAsync(qrCode.LogoAsset.StorageKey);
|
||||
if (logoResult.HasValue)
|
||||
{
|
||||
logoStream = logoResult.Value.Stream;
|
||||
}
|
||||
}
|
||||
|
||||
var response = new QRCodePreviewResponse(
|
||||
DataUrl: dataUrl,
|
||||
Format: "png",
|
||||
Width: size,
|
||||
Height: size
|
||||
);
|
||||
try
|
||||
{
|
||||
var dataUrl = qrGenerator.GenerateDataUrl(linkUrl, style, size, logoStream);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
var response = new QRCodePreviewResponse(
|
||||
DataUrl: dataUrl,
|
||||
Format: "png",
|
||||
Width: size,
|
||||
Height: size
|
||||
);
|
||||
|
||||
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
logoStream?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ public class UpdateQRCodeRequest
|
||||
{
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public bool? RemoveProject { get; set; }
|
||||
public Guid? LogoAssetId { get; set; }
|
||||
public bool? RemoveLogo { get; set; }
|
||||
public QRCodeStyle? Style { get; set; }
|
||||
}
|
||||
|
||||
@@ -32,6 +35,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
var qrCode = await db.QrCodeDesigns
|
||||
.Include(q => q.Workspace)
|
||||
.Include(q => q.ShortLink)
|
||||
.Include(q => q.LogoAsset)
|
||||
.FirstOrDefaultAsync(q => q.Id == req.Id && q.WorkspaceId == req.WorkspaceId && q.Workspace.OwnerUserId == userId, ct);
|
||||
|
||||
if (qrCode is null)
|
||||
@@ -58,6 +62,33 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.ProjectId = null;
|
||||
}
|
||||
|
||||
// Update name if provided
|
||||
if (!string.IsNullOrWhiteSpace(req.Name))
|
||||
{
|
||||
qrCode.Name = req.Name;
|
||||
}
|
||||
|
||||
// Handle logo asset update
|
||||
if (req.LogoAssetId.HasValue)
|
||||
{
|
||||
var assetExists = await db.Assets
|
||||
.AnyAsync(a => a.Id == req.LogoAssetId.Value && a.WorkspaceId == req.WorkspaceId, ct);
|
||||
|
||||
if (!assetExists)
|
||||
{
|
||||
await HttpContext.Response.SendAsync(new MessageResponse("Logo asset not found"), 404, cancellation: ct);
|
||||
return;
|
||||
}
|
||||
qrCode.LogoAssetId = req.LogoAssetId.Value;
|
||||
// Reload the asset for the response
|
||||
qrCode.LogoAsset = await db.Assets.FindAsync([req.LogoAssetId.Value], ct);
|
||||
}
|
||||
else if (req.RemoveLogo == true)
|
||||
{
|
||||
qrCode.LogoAssetId = null;
|
||||
qrCode.LogoAsset = null;
|
||||
}
|
||||
|
||||
if (req.Style != null)
|
||||
{
|
||||
qrCode.StyleJson = JsonSerializer.Serialize(req.Style);
|
||||
@@ -67,6 +98,7 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var style = JsonSerializer.Deserialize<QRCodeStyle>(qrCode.StyleJson) ?? new QRCodeStyle();
|
||||
var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
|
||||
|
||||
var response = new QRCodeResponse(
|
||||
qrCode.Id,
|
||||
@@ -74,8 +106,10 @@ public class UpdateQRCodeEndpoint(AppDbContext db)
|
||||
qrCode.ProjectId,
|
||||
qrCode.ShortLinkId,
|
||||
qrCode.ShortLink?.Slug,
|
||||
qrCode.Name,
|
||||
style,
|
||||
qrCode.LogoAssetId,
|
||||
qrCode.LogoAsset != null ? $"{baseUrl}/assets/{qrCode.LogoAsset.StorageKey}" : null,
|
||||
qrCode.CreatedAt,
|
||||
qrCode.UpdatedAt
|
||||
);
|
||||
|
||||
@@ -1,34 +1,129 @@
|
||||
using System.Drawing;
|
||||
using api.Features.QRCodes.Common;
|
||||
using QRCoder;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace api.Features.QRCodes.Services;
|
||||
|
||||
public interface IQRCodeGeneratorService
|
||||
public interface IQrCodeGeneratorService
|
||||
{
|
||||
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512);
|
||||
byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null);
|
||||
string GenerateSvg(string content, QRCodeStyle style, int size = 512);
|
||||
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256);
|
||||
string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null);
|
||||
}
|
||||
|
||||
public class QRCodeGeneratorService : IQRCodeGeneratorService
|
||||
public class QrCodeGeneratorService : IQrCodeGeneratorService
|
||||
{
|
||||
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512)
|
||||
public byte[] GeneratePng(string content, QRCodeStyle style, int size = 512, Stream? logoStream = null)
|
||||
{
|
||||
using var qrGenerator = new QRCodeGenerator();
|
||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||
|
||||
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
var foreground = ParseColor(style.ForegroundColor);
|
||||
var background = ParseColor(style.BackgroundColor);
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + (style.QuietZone * 2);
|
||||
var pixelsPerModule = Math.Max(4, size / totalModules);
|
||||
var actualSize = totalModules * pixelsPerModule;
|
||||
|
||||
// Calculate pixels per module based on desired size
|
||||
var moduleCount = qrCodeData.ModuleMatrix.Count;
|
||||
var pixelsPerModule = Math.Max(1, size / moduleCount);
|
||||
// Create bitmap with SkiaSharp for custom shapes
|
||||
var foregroundColor = ParseSkColor(style.ForegroundColor);
|
||||
var backgroundColor = ParseSkColor(style.BackgroundColor);
|
||||
|
||||
return qrCode.GetGraphic(pixelsPerModule, foreground, background, drawQuietZones: style.QuietZone > 0);
|
||||
using var surface = SKSurface.Create(new SKImageInfo(actualSize, actualSize));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// Draw background
|
||||
canvas.Clear(backgroundColor);
|
||||
|
||||
// Draw QR modules with custom shapes
|
||||
var modulePaint = new SKPaint
|
||||
{
|
||||
Color = foregroundColor,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (int y = 0; y < moduleCount; y++)
|
||||
{
|
||||
for (int x = 0; x < moduleCount; x++)
|
||||
{
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + (x * pixelsPerModule);
|
||||
var py = quietZoneOffset + (y * pixelsPerModule);
|
||||
|
||||
// Check if this is part of a finder pattern (eyes)
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
|
||||
if (isEye)
|
||||
{
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.EyeShape);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawModule(canvas, px, py, pixelsPerModule, modulePaint, style.ModuleShape);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
var qrBytes = data.ToArray();
|
||||
|
||||
// If no logo, return the QR code as-is
|
||||
if (logoStream == null)
|
||||
{
|
||||
return qrBytes;
|
||||
}
|
||||
|
||||
// Overlay logo on QR code
|
||||
return OverlayLogo(qrBytes, logoStream, actualSize);
|
||||
}
|
||||
|
||||
private static bool IsFinderPattern(int x, int y, int moduleCount)
|
||||
{
|
||||
// Top-left finder pattern: 0-6, 0-6
|
||||
if (x <= 6 && y <= 6) return true;
|
||||
// Top-right finder pattern: moduleCount-7 to moduleCount-1, 0-6
|
||||
if (x >= moduleCount - 7 && y <= 6) return true;
|
||||
// Bottom-left finder pattern: 0-6, moduleCount-7 to moduleCount-1
|
||||
if (x <= 6 && y >= moduleCount - 7) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void DrawModule(SKCanvas canvas, float x, float y, float size, SKPaint paint, string shape)
|
||||
{
|
||||
var padding = size * 0.1f; // 10% padding between modules
|
||||
var moduleSize = size - padding;
|
||||
|
||||
switch (shape.ToLowerInvariant())
|
||||
{
|
||||
case "circle":
|
||||
case "dots":
|
||||
var radius = moduleSize / 2;
|
||||
canvas.DrawCircle(x + size / 2, y + size / 2, radius, paint);
|
||||
break;
|
||||
|
||||
case "rounded":
|
||||
var cornerRadius = moduleSize * 0.3f;
|
||||
var rect = new SKRoundRect(
|
||||
new SKRect(x + padding / 2, y + padding / 2, x + size - padding / 2, y + size - padding / 2),
|
||||
cornerRadius
|
||||
);
|
||||
canvas.DrawRoundRect(rect, paint);
|
||||
break;
|
||||
|
||||
case "square":
|
||||
default:
|
||||
canvas.DrawRect(x + padding / 2, y + padding / 2, moduleSize, moduleSize, paint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public string GenerateSvg(string content, QRCodeStyle style, int size = 512)
|
||||
@@ -37,30 +132,120 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService
|
||||
var eccLevel = ParseEccLevel(style.ErrorCorrectionLevel);
|
||||
using var qrCodeData = qrGenerator.CreateQrCode(content, eccLevel);
|
||||
|
||||
using var qrCode = new SvgQRCode(qrCodeData);
|
||||
var moduleMatrix = qrCodeData.ModuleMatrix;
|
||||
var moduleCount = moduleMatrix.Count;
|
||||
|
||||
// Calculate pixels per module based on desired size (accounting for quiet zone)
|
||||
var totalModules = moduleCount + (style.QuietZone * 2);
|
||||
var pixelsPerModule = (float)size / totalModules;
|
||||
var actualSize = size;
|
||||
|
||||
var foreground = style.ForegroundColor;
|
||||
var background = style.BackgroundColor;
|
||||
|
||||
// Calculate pixels per module
|
||||
var moduleCount = qrCodeData.ModuleMatrix.Count;
|
||||
var pixelsPerModule = Math.Max(1, size / moduleCount);
|
||||
var svg = new System.Text.StringBuilder();
|
||||
svg.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {actualSize} {actualSize}\" width=\"{actualSize}\" height=\"{actualSize}\">");
|
||||
svg.AppendLine($" <rect width=\"100%\" height=\"100%\" fill=\"{background}\"/>");
|
||||
|
||||
return qrCode.GetGraphic(
|
||||
pixelsPerModule,
|
||||
foreground,
|
||||
background,
|
||||
drawQuietZones: style.QuietZone > 0
|
||||
);
|
||||
var quietZoneOffset = style.QuietZone * pixelsPerModule;
|
||||
|
||||
for (int y = 0; y < moduleCount; y++)
|
||||
{
|
||||
for (int x = 0; x < moduleCount; x++)
|
||||
{
|
||||
if (moduleMatrix[y][x])
|
||||
{
|
||||
var px = quietZoneOffset + (x * pixelsPerModule);
|
||||
var py = quietZoneOffset + (y * pixelsPerModule);
|
||||
var isEye = IsFinderPattern(x, y, moduleCount);
|
||||
var shape = isEye ? style.EyeShape : style.ModuleShape;
|
||||
|
||||
var padding = pixelsPerModule * 0.1f;
|
||||
var moduleSize = pixelsPerModule - padding;
|
||||
|
||||
switch (shape.ToLowerInvariant())
|
||||
{
|
||||
case "circle":
|
||||
case "dots":
|
||||
var radius = moduleSize / 2;
|
||||
var cx = px + pixelsPerModule / 2;
|
||||
var cy = py + pixelsPerModule / 2;
|
||||
svg.AppendLine($" <circle cx=\"{cx:F2}\" cy=\"{cy:F2}\" r=\"{radius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "rounded":
|
||||
var cornerRadius = moduleSize * 0.3f;
|
||||
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" rx=\"{cornerRadius:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
|
||||
case "square":
|
||||
default:
|
||||
svg.AppendLine($" <rect x=\"{px + padding / 2:F2}\" y=\"{py + padding / 2:F2}\" width=\"{moduleSize:F2}\" height=\"{moduleSize:F2}\" fill=\"{foreground}\"/>");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg.AppendLine("</svg>");
|
||||
return svg.ToString();
|
||||
}
|
||||
|
||||
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256)
|
||||
public string GenerateDataUrl(string content, QRCodeStyle style, int size = 256, Stream? logoStream = null)
|
||||
{
|
||||
var pngBytes = GeneratePng(content, style, size);
|
||||
var pngBytes = GeneratePng(content, style, size, logoStream);
|
||||
var base64 = Convert.ToBase64String(pngBytes);
|
||||
return $"data:image/png;base64,{base64}";
|
||||
}
|
||||
|
||||
private static byte[] OverlayLogo(byte[] qrBytes, Stream logoStream, int qrSize)
|
||||
{
|
||||
using var qrBitmap = SKBitmap.Decode(qrBytes);
|
||||
using var logoBitmap = SKBitmap.Decode(logoStream);
|
||||
|
||||
if (qrBitmap == null || logoBitmap == null)
|
||||
{
|
||||
return qrBytes;
|
||||
}
|
||||
|
||||
// Logo should be about 20% of QR code size
|
||||
var logoSize = (int)(qrSize * 0.2);
|
||||
var logoX = (qrBitmap.Width - logoSize) / 2;
|
||||
var logoY = (qrBitmap.Height - logoSize) / 2;
|
||||
|
||||
// Create a new surface to draw on
|
||||
using var surface = SKSurface.Create(new SKImageInfo(qrBitmap.Width, qrBitmap.Height));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// Draw QR code
|
||||
canvas.DrawBitmap(qrBitmap, 0, 0);
|
||||
|
||||
// Draw white background circle for logo
|
||||
var circlePaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true,
|
||||
Style = SKPaintStyle.Fill
|
||||
};
|
||||
var circleRadius = logoSize * 0.6f;
|
||||
canvas.DrawCircle(qrBitmap.Width / 2f, qrBitmap.Height / 2f, circleRadius, circlePaint);
|
||||
|
||||
// Resize and draw logo
|
||||
using var resizedLogo = logoBitmap.Resize(
|
||||
new SKImageInfo(logoSize, logoSize),
|
||||
new SKSamplingOptions(SKCubicResampler.Mitchell));
|
||||
if (resizedLogo != null)
|
||||
{
|
||||
canvas.DrawBitmap(resizedLogo, logoX, logoY);
|
||||
}
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
|
||||
return data.ToArray();
|
||||
}
|
||||
|
||||
private static QRCodeGenerator.ECCLevel ParseEccLevel(string level)
|
||||
{
|
||||
return level.ToUpperInvariant() switch
|
||||
@@ -73,22 +258,20 @@ public class QRCodeGeneratorService : IQRCodeGeneratorService
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] ParseColor(string hexColor)
|
||||
private static SKColor ParseSkColor(string hexColor)
|
||||
{
|
||||
// Remove # if present
|
||||
var hex = hexColor.TrimStart('#');
|
||||
|
||||
if (hex.Length == 6)
|
||||
{
|
||||
return
|
||||
[
|
||||
Convert.ToByte(hex[..2], 16),
|
||||
Convert.ToByte(hex[2..4], 16),
|
||||
Convert.ToByte(hex[4..6], 16)
|
||||
];
|
||||
var r = Convert.ToByte(hex[..2], 16);
|
||||
var g = Convert.ToByte(hex[2..4], 16);
|
||||
var b = Convert.ToByte(hex[4..6], 16);
|
||||
return new SKColor(r, g, b);
|
||||
}
|
||||
|
||||
// Default to black
|
||||
return [0, 0, 0];
|
||||
return SKColors.Black;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace api.Features.Redirect.Endpoints;
|
||||
public class RedirectRequest
|
||||
{
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public Guid? Qr { get; set; }
|
||||
}
|
||||
|
||||
public class RedirectResponse
|
||||
@@ -24,6 +25,7 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
|
||||
{
|
||||
Get("/{Slug}");
|
||||
AllowAnonymous();
|
||||
Options(x => x.RequireRateLimiting("redirect"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(RedirectRequest req, CancellationToken ct)
|
||||
@@ -73,8 +75,16 @@ public class RedirectEndpoint(AppDbContext db, IEventTrackingService eventTracki
|
||||
return;
|
||||
}
|
||||
|
||||
// Track click event asynchronously (fire and forget)
|
||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||
// Track event asynchronously (fire and forget)
|
||||
// If qr parameter is present, track as scan; otherwise track as click
|
||||
if (req.Qr.HasValue)
|
||||
{
|
||||
await eventTracking.TrackScanAsync(link.WorkspaceId, link.Id, req.Qr.Value, HttpContext);
|
||||
}
|
||||
else
|
||||
{
|
||||
await eventTracking.TrackClickAsync(link.WorkspaceId, link.Id, HttpContext);
|
||||
}
|
||||
|
||||
// Redirect to destination (302 Found)
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status302Found;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using api.Data;
|
||||
using api.Features.Auth.Common;
|
||||
using api.Features.Plans.Services;
|
||||
using api.Features.Workspaces.Common;
|
||||
using api.Models;
|
||||
using FastEndpoints;
|
||||
@@ -22,7 +24,7 @@ public class CreateWorkspaceValidator : Validator<CreateWorkspaceRequest>
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateWorkspaceEndpoint(AppDbContext db)
|
||||
public class CreateWorkspaceEndpoint(AppDbContext db, IPlanLimitsService planLimits)
|
||||
: Endpoint<CreateWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -34,6 +36,16 @@ public class CreateWorkspaceEndpoint(AppDbContext db)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
// Check plan limits
|
||||
if (!await planLimits.CanCreateWorkspaceAsync(userId, ct))
|
||||
{
|
||||
await HttpContext.Response.SendAsync(
|
||||
new MessageResponse("Workspace limit reached. Please upgrade your plan to create more workspaces."),
|
||||
402,
|
||||
cancellation: ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var workspace = new Workspace
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
||||
55
src/api/Middleware/GlobalExceptionMiddleware.cs
Normal file
55
src/api/Middleware/GlobalExceptionMiddleware.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace api.Middleware;
|
||||
|
||||
public class GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await HandleExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
||||
{
|
||||
var (statusCode, message) = exception switch
|
||||
{
|
||||
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "Unauthorized access"),
|
||||
KeyNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
|
||||
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
|
||||
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
|
||||
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred")
|
||||
};
|
||||
|
||||
// Log the exception
|
||||
if (statusCode == HttpStatusCode.InternalServerError)
|
||||
{
|
||||
logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("Request error: {StatusCode} - {Message}", (int)statusCode, message);
|
||||
}
|
||||
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = (int)statusCode;
|
||||
|
||||
var response = new ErrorResponse(
|
||||
StatusCode: (int)statusCode,
|
||||
Message: message,
|
||||
TraceId: context.TraceIdentifier
|
||||
);
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, options));
|
||||
}
|
||||
}
|
||||
|
||||
public record ErrorResponse(int StatusCode, string Message, string TraceId);
|
||||
708
src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs
generated
Normal file
708
src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.Designer.cs
generated
Normal file
@@ -0,0 +1,708 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260130185641_AddQRCodeNameAndLogo")]
|
||||
partial class AddQRCodeNameAndLogo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("api.Models.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("KeyPrefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Scopes")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeyHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("EmailVerificationTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("QRCodeId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("ShortLinkId", "Timestamp");
|
||||
|
||||
b.HasIndex("WorkspaceId", "Timestamp");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LogoAssetId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("ShortLinkId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("QrCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("DomainId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndsAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Domain", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Domains")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("api.Models.QRCodeDesign", "QRCode")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("QRCodeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.ShortLink", "ShortLink")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QRCode");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Asset", "LogoAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("LogoAssetId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Project", "Project")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.ShortLink", "ShortLink")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("LogoAsset");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Domain", "Domain")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Project", "Project")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "Owner")
|
||||
.WithMany("Workspaces")
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Domain", b =>
|
||||
{
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
|
||||
b.Navigation("Domains");
|
||||
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("Projects");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
182
src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.cs
Normal file
182
src/api/Migrations/20260130185641_AddQRCodeNameAndLogo.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddQRCodeNameAndLogo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripeSubscriptionId",
|
||||
table: "Workspaces",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SubscriptionEndsAt",
|
||||
table: "Workspaces",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripeCustomerId",
|
||||
table: "Users",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "DeletedAt",
|
||||
table: "ShortLinks",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Name",
|
||||
table: "QrCodeDesigns",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
KeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
KeyPrefix = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
|
||||
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Scopes = table.Column<List<string>>(type: "text[]", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKeys", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ApiKeys_Workspaces_WorkspaceId",
|
||||
column: x => x.WorkspaceId,
|
||||
principalTable: "Workspaces",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmailVerificationTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmailVerificationTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmailVerificationTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PasswordResetTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Used = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PasswordResetTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PasswordResetTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyHash",
|
||||
table: "ApiKeys",
|
||||
column: "KeyHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_WorkspaceId",
|
||||
table: "ApiKeys",
|
||||
column: "WorkspaceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailVerificationTokens_Token",
|
||||
table: "EmailVerificationTokens",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmailVerificationTokens_UserId",
|
||||
table: "EmailVerificationTokens",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PasswordResetTokens_Token",
|
||||
table: "PasswordResetTokens",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PasswordResetTokens_UserId",
|
||||
table: "PasswordResetTokens",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmailVerificationTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PasswordResetTokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripeSubscriptionId",
|
||||
table: "Workspaces");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubscriptionEndsAt",
|
||||
table: "Workspaces");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripeCustomerId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletedAt",
|
||||
table: "ShortLinks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Name",
|
||||
table: "QrCodeDesigns");
|
||||
}
|
||||
}
|
||||
}
|
||||
711
src/api/Migrations/20260130193730_AddProjectDescription.Designer.cs
generated
Normal file
711
src/api/Migrations/20260130193730_AddProjectDescription.Designer.cs
generated
Normal file
@@ -0,0 +1,711 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260130193730_AddProjectDescription")]
|
||||
partial class AddProjectDescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("api.Models.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("KeyPrefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Scopes")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeyHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Mime")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StorageKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Domain", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Hostname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("VerificationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Hostname")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("EmailVerificationTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DedupeKey")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("DeviceType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("IpHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid?>("QRCodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Referrer")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("QRCodeId");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("ShortLinkId", "Timestamp");
|
||||
|
||||
b.HasIndex("WorkspaceId", "Timestamp");
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ShortLinkId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("StyleJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LogoAssetId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("ShortLinkId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("QrCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid?>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.HasIndex("DomainId", "Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndsAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Domain", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Domains")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("api.Models.QRCodeDesign", "QRCode")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("QRCodeId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.ShortLink", "ShortLink")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("QRCode");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Asset", "LogoAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("LogoAssetId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Project", "Project")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.ShortLink", "ShortLink")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("ShortLinkId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("QRCodeDesigns")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("LogoAsset");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("ShortLink");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Domain", "Domain")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("DomainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Project", "Project")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany("ShortLinks")
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Domain");
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "Owner")
|
||||
.WithMany("Workspaces")
|
||||
.HasForeignKey("OwnerUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Domain", b =>
|
||||
{
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
|
||||
b.Navigation("Domains");
|
||||
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("Projects");
|
||||
|
||||
b.Navigation("QRCodeDesigns");
|
||||
|
||||
b.Navigation("ShortLinks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/api/Migrations/20260130193730_AddProjectDescription.cs
Normal file
28
src/api/Migrations/20260130193730_AddProjectDescription.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProjectDescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Description",
|
||||
table: "Projects",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Description",
|
||||
table: "Projects");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -22,6 +23,57 @@ namespace api.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("api.Models.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("KeyPrefix")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Scopes")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid>("WorkspaceId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeyHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("WorkspaceId");
|
||||
|
||||
b.ToTable("ApiKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -100,6 +152,38 @@ namespace api.Migrations
|
||||
b.ToTable("Domains");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("EmailVerificationTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Event", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -164,6 +248,41 @@ namespace api.Migrations
|
||||
b.ToTable("Events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -175,6 +294,9 @@ namespace api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -204,6 +326,10 @@ namespace api.Migrations
|
||||
b.Property<Guid?>("LogoAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -246,6 +372,9 @@ namespace api.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DestinationUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
@@ -319,6 +448,9 @@ namespace api.Migrations
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -354,6 +486,12 @@ namespace api.Migrations
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("SubscriptionEndsAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
@@ -361,6 +499,17 @@ namespace api.Migrations
|
||||
b.ToTable("Workspaces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorkspaceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
@@ -383,6 +532,17 @@ namespace api.Migrations
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.EmailVerificationToken", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Event", b =>
|
||||
{
|
||||
b.HasOne("api.Models.QRCodeDesign", "QRCode")
|
||||
@@ -409,6 +569,17 @@ namespace api.Migrations
|
||||
b.Navigation("Workspace");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("api.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("api.Models.Project", b =>
|
||||
{
|
||||
b.HasOne("api.Models.Workspace", "Workspace")
|
||||
|
||||
17
src/api/Models/ApiKey.cs
Normal file
17
src/api/Models/ApiKey.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace api.Models;
|
||||
|
||||
public class ApiKey
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string KeyHash { get; set; } // Only store hash, never the raw key
|
||||
public required string KeyPrefix { get; set; } // First 8 chars for identification (e.g., "trk_abc1...")
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public List<string>? Scopes { get; set; } // e.g., ["links:read", "links:write", "qrcodes:read"]
|
||||
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
}
|
||||
13
src/api/Models/EmailVerificationToken.cs
Normal file
13
src/api/Models/EmailVerificationToken.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace api.Models;
|
||||
|
||||
public class EmailVerificationToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public required string Token { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
14
src/api/Models/PasswordResetToken.cs
Normal file
14
src/api/Models/PasswordResetToken.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace api.Models;
|
||||
|
||||
public class PasswordResetToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public required string Token { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public bool Used { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ public class Project
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
@@ -6,6 +6,7 @@ public class QRCodeDesign
|
||||
public Guid WorkspaceId { get; set; }
|
||||
public Guid? ProjectId { get; set; }
|
||||
public Guid? ShortLinkId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string StyleJson { get; set; }
|
||||
public Guid? LogoAssetId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
@@ -20,6 +20,7 @@ public class ShortLink
|
||||
public string? PasswordHash { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime? DeletedAt { get; set; } // Soft delete
|
||||
|
||||
// Navigation properties
|
||||
public Workspace Workspace { get; set; } = null!;
|
||||
|
||||
@@ -5,6 +5,7 @@ public class User
|
||||
public Guid Id { get; set; }
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public string? StripeCustomerId { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ public class Workspace
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public WorkspacePlan Plan { get; set; } = WorkspacePlan.Free;
|
||||
public string? StripeSubscriptionId { get; set; }
|
||||
public DateTime? SubscriptionEndsAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
@@ -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();
|
||||
@@ -13,6 +13,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="FastEndpoints" Version="7.2.0" />
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
@@ -21,6 +22,12 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.7.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.116.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.116.1" />
|
||||
<PackageReference Include="Stripe.net" Version="47.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,6 +9,24 @@
|
||||
"PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!"
|
||||
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!",
|
||||
"Issuer": "TrakQR",
|
||||
"Audience": "TrakQR",
|
||||
"ExpirationMinutes": 60
|
||||
},
|
||||
"Email": {
|
||||
"Provider": "console",
|
||||
"FromEmail": "noreply@trakqr.local",
|
||||
"FromName": "TrakQR",
|
||||
"BaseUrl": "http://localhost:5173"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": ["http://localhost:5173", "https://localhost:5173"]
|
||||
},
|
||||
"Stripe": {
|
||||
"SecretKey": "sk_test_your_test_key_here",
|
||||
"WebhookSecret": "whsec_your_webhook_secret_here",
|
||||
"ProPriceId": "price_pro_monthly",
|
||||
"BusinessPriceId": "price_business_monthly"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,30 @@
|
||||
"Issuer": "TrakQR",
|
||||
"Audience": "TrakQR",
|
||||
"ExpirationMinutes": 60
|
||||
},
|
||||
"Email": {
|
||||
"Provider": "smtp",
|
||||
"FromEmail": "noreply@trakqr.com",
|
||||
"FromName": "TrakQR",
|
||||
"BaseUrl": "https://trakqr.com",
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"UseSsl": true,
|
||||
"Username": "",
|
||||
"Password": ""
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": ["https://trakqr.com"]
|
||||
},
|
||||
"GeoIP": {
|
||||
"DatabasePath": ""
|
||||
},
|
||||
"Stripe": {
|
||||
"SecretKey": "",
|
||||
"WebhookSecret": "",
|
||||
"ProPriceId": "",
|
||||
"BusinessPriceId": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useWorkspaceStore } from './stores/workspace';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize auth first
|
||||
await authStore.initialize();
|
||||
|
||||
// If authenticated, initialize workspace
|
||||
if (authStore.isAuthenticated) {
|
||||
await workspaceStore.initialize();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -77,6 +77,38 @@ class ApiClient {
|
||||
return this.request('POST', '/auth/login', { email, password });
|
||||
}
|
||||
|
||||
forgotPassword(email) {
|
||||
return this.request('POST', '/auth/forgot', { email });
|
||||
}
|
||||
|
||||
resetPassword(token, newPassword) {
|
||||
return this.request('POST', '/auth/reset', { token, newPassword });
|
||||
}
|
||||
|
||||
getProfile() {
|
||||
return this.request('GET', '/auth/profile');
|
||||
}
|
||||
|
||||
updateProfile(data) {
|
||||
return this.request('PUT', '/auth/profile', data);
|
||||
}
|
||||
|
||||
changePassword(currentPassword, newPassword) {
|
||||
return this.request('POST', '/auth/change-password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
resendVerification() {
|
||||
return this.request('POST', '/auth/resend-verification');
|
||||
}
|
||||
|
||||
verifyEmail(token) {
|
||||
return this.request('POST', '/auth/verify-email', { token });
|
||||
}
|
||||
|
||||
deleteAccount(password) {
|
||||
return this.request('DELETE', '/auth/account', { password });
|
||||
}
|
||||
|
||||
// Workspaces
|
||||
listWorkspaces() {
|
||||
return this.request('GET', '/workspaces');
|
||||
@@ -126,6 +158,10 @@ class ApiClient {
|
||||
return this.request('GET', path);
|
||||
}
|
||||
|
||||
restoreLink(workspaceId, id) {
|
||||
return this.request('POST', `/workspaces/${workspaceId}/links/${id}/restore`);
|
||||
}
|
||||
|
||||
createLink(workspaceId, data) {
|
||||
return this.request('POST', `/workspaces/${workspaceId}/links`, data);
|
||||
}
|
||||
@@ -142,8 +178,19 @@ class ApiClient {
|
||||
return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`);
|
||||
}
|
||||
|
||||
getLinkAnalytics(workspaceId, linkId, period = '7d') {
|
||||
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?period=${period}`);
|
||||
bulkCreateLinks(workspaceId, links) {
|
||||
return this.request('POST', `/workspaces/${workspaceId}/links/bulk`, { links });
|
||||
}
|
||||
|
||||
getLinkAnalytics(workspaceId, linkId, period = '7d', startDate = null, endDate = null) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate && endDate) {
|
||||
params.set('startDate', startDate);
|
||||
params.set('endDate', endDate);
|
||||
} else {
|
||||
params.set('period', period);
|
||||
}
|
||||
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?${params.toString()}`);
|
||||
}
|
||||
|
||||
// QR Codes
|
||||
@@ -175,9 +222,20 @@ class ApiClient {
|
||||
return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`;
|
||||
}
|
||||
|
||||
getQRCodeAnalytics(workspaceId, qrCodeId, period = '7d') {
|
||||
return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${qrCodeId}/analytics?period=${period}`);
|
||||
}
|
||||
|
||||
// Analytics
|
||||
getWorkspaceAnalytics(workspaceId, period = '7d') {
|
||||
return this.request('GET', `/workspaces/${workspaceId}/analytics?period=${period}`);
|
||||
getWorkspaceAnalytics(workspaceId, period = '7d', startDate = null, endDate = null) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate && endDate) {
|
||||
params.set('startDate', startDate);
|
||||
params.set('endDate', endDate);
|
||||
} else {
|
||||
params.set('period', period);
|
||||
}
|
||||
return this.request('GET', `/workspaces/${workspaceId}/analytics?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Domains
|
||||
@@ -209,6 +267,43 @@ class ApiClient {
|
||||
deleteAsset(workspaceId, id) {
|
||||
return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`);
|
||||
}
|
||||
|
||||
// Billing
|
||||
createCheckoutSession(workspaceId, plan, successUrl, cancelUrl) {
|
||||
return this.request('POST', '/billing/checkout', {
|
||||
workspaceId,
|
||||
plan,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
});
|
||||
}
|
||||
|
||||
createPortalSession(returnUrl) {
|
||||
return this.request('POST', '/billing/portal', { returnUrl });
|
||||
}
|
||||
|
||||
getSubscription(workspaceId) {
|
||||
return this.request('GET', `/workspaces/${workspaceId}/subscription`);
|
||||
}
|
||||
|
||||
// Usage
|
||||
getUsage(workspaceId = null) {
|
||||
const path = workspaceId ? `/usage?workspaceId=${workspaceId}` : '/usage';
|
||||
return this.request('GET', path);
|
||||
}
|
||||
|
||||
// API Keys
|
||||
listApiKeys(workspaceId) {
|
||||
return this.request('GET', `/workspaces/${workspaceId}/api-keys`);
|
||||
}
|
||||
|
||||
createApiKey(workspaceId, name, expiresAt = null, scopes = null) {
|
||||
return this.request('POST', `/workspaces/${workspaceId}/api-keys`, { name, expiresAt, scopes });
|
||||
}
|
||||
|
||||
deleteApiKey(workspaceId, id) {
|
||||
return this.request('DELETE', `/workspaces/${workspaceId}/api-keys/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
@@ -9,19 +9,141 @@
|
||||
</div>
|
||||
|
||||
<div class="workspace-selector" v-if="workspaceStore.currentWorkspace">
|
||||
<select
|
||||
:value="workspaceStore.currentWorkspace?.id"
|
||||
@change="onWorkspaceChange"
|
||||
class="workspace-select"
|
||||
>
|
||||
<option
|
||||
v-for="ws in workspaceStore.workspaces"
|
||||
:key="ws.id"
|
||||
:value="ws.id"
|
||||
<div class="workspace-dropdown">
|
||||
<select
|
||||
:value="workspaceStore.currentWorkspace?.id"
|
||||
@change="onWorkspaceChange"
|
||||
class="workspace-select"
|
||||
>
|
||||
{{ ws.name }}
|
||||
</option>
|
||||
</select>
|
||||
<option
|
||||
v-for="ws in workspaceStore.workspaces"
|
||||
:key="ws.id"
|
||||
:value="ws.id"
|
||||
>
|
||||
{{ ws.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="workspace-actions">
|
||||
<button class="ws-action-btn" @click="showCreateWorkspace = true" title="Create workspace">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="ws-action-btn" @click="showWorkspaceSettings = true" title="Workspace settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Workspace Modal -->
|
||||
<div v-if="showCreateWorkspace" class="modal-overlay" @click.self="showCreateWorkspace = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Create Workspace</h2>
|
||||
<button class="close-btn" @click="showCreateWorkspace = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="createWorkspace">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="ws-name">Workspace Name</label>
|
||||
<input
|
||||
id="ws-name"
|
||||
v-model="newWorkspaceName"
|
||||
type="text"
|
||||
placeholder="e.g., Marketing Team"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div v-if="wsError" class="error-message">{{ wsError }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateWorkspace = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="creatingWs">
|
||||
{{ creatingWs ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Settings Modal -->
|
||||
<div v-if="showWorkspaceSettings" class="modal-overlay" @click.self="showWorkspaceSettings = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Workspace Settings</h2>
|
||||
<button class="close-btn" @click="showWorkspaceSettings = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="updateWorkspace">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="ws-edit-name">Workspace Name</label>
|
||||
<input
|
||||
id="ws-edit-name"
|
||||
v-model="editWorkspaceName"
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="workspace-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Plan:</span>
|
||||
<span class="info-value plan-badge" :class="workspaceStore.currentWorkspace?.plan?.toLowerCase()">
|
||||
{{ workspaceStore.currentWorkspace?.plan }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Created:</span>
|
||||
<span class="info-value">{{ formatDate(workspaceStore.currentWorkspace?.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="wsError" class="error-message">{{ wsError }}</div>
|
||||
</div>
|
||||
<div class="modal-actions split">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger-outline"
|
||||
@click="confirmDeleteWorkspace"
|
||||
:disabled="workspaceStore.workspaces.length <= 1"
|
||||
:title="workspaceStore.workspaces.length <= 1 ? 'Cannot delete your only workspace' : 'Delete workspace'"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div class="action-group">
|
||||
<button type="button" class="btn btn-secondary" @click="showWorkspaceSettings = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="updatingWs">
|
||||
{{ updatingWs ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Workspace Confirmation -->
|
||||
<div v-if="showDeleteWorkspace" class="modal-overlay" @click.self="showDeleteWorkspace = false">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2>Delete Workspace</h2>
|
||||
<button class="close-btn" @click="showDeleteWorkspace = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="delete-warning">
|
||||
Are you sure you want to delete <strong>{{ workspaceStore.currentWorkspace?.name }}</strong>?
|
||||
All links, QR codes, and analytics data will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDeleteWorkspace = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteWorkspace" :disabled="deletingWs">
|
||||
{{ deletingWs ? 'Deleting...' : 'Delete Workspace' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
@@ -59,6 +181,34 @@
|
||||
</svg>
|
||||
Analytics
|
||||
</router-link>
|
||||
<router-link to="/projects" class="nav-item" :class="{ active: $route.name === 'projects' }">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Projects
|
||||
</router-link>
|
||||
<router-link to="/domains" class="nav-item" :class="{ active: $route.name === 'domains' }">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
Domains
|
||||
</router-link>
|
||||
<router-link to="/billing" class="nav-item" :class="{ active: $route.name === 'billing' }">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
|
||||
<line x1="1" y1="10" x2="23" y2="10"/>
|
||||
</svg>
|
||||
Billing
|
||||
</router-link>
|
||||
<router-link to="/settings" class="nav-item" :class="{ active: $route.name === 'settings' }">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
Settings
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
@@ -80,7 +230,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
@@ -89,11 +239,29 @@ const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
// Workspace management state
|
||||
const showCreateWorkspace = ref(false);
|
||||
const showWorkspaceSettings = ref(false);
|
||||
const showDeleteWorkspace = ref(false);
|
||||
const newWorkspaceName = ref('');
|
||||
const editWorkspaceName = ref('');
|
||||
const wsError = ref('');
|
||||
const creatingWs = ref(false);
|
||||
const updatingWs = ref(false);
|
||||
const deletingWs = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
authStore.checkAuth();
|
||||
await workspaceStore.fetchWorkspaces();
|
||||
// Ensure stores are initialized (in case component mounts before App.vue init completes)
|
||||
await authStore.initialize();
|
||||
await workspaceStore.initialize();
|
||||
});
|
||||
|
||||
watch(() => workspaceStore.currentWorkspace, (ws) => {
|
||||
if (ws) {
|
||||
editWorkspaceName.value = ws.name;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const onWorkspaceChange = (e) => {
|
||||
const workspace = workspaceStore.workspaces.find(w => w.id === e.target.value);
|
||||
if (workspace) {
|
||||
@@ -101,7 +269,65 @@ const onWorkspaceChange = (e) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createWorkspace = async () => {
|
||||
creatingWs.value = true;
|
||||
wsError.value = '';
|
||||
try {
|
||||
const workspace = await workspaceStore.createWorkspace(newWorkspaceName.value);
|
||||
workspaceStore.setCurrentWorkspace(workspace);
|
||||
showCreateWorkspace.value = false;
|
||||
newWorkspaceName.value = '';
|
||||
} catch (err) {
|
||||
wsError.value = err.message;
|
||||
} finally {
|
||||
creatingWs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateWorkspace = async () => {
|
||||
updatingWs.value = true;
|
||||
wsError.value = '';
|
||||
try {
|
||||
const wsId = workspaceStore.currentWorkspace?.id;
|
||||
await workspaceStore.updateWorkspace(wsId, editWorkspaceName.value);
|
||||
showWorkspaceSettings.value = false;
|
||||
} catch (err) {
|
||||
wsError.value = err.message;
|
||||
} finally {
|
||||
updatingWs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteWorkspace = () => {
|
||||
showWorkspaceSettings.value = false;
|
||||
showDeleteWorkspace.value = true;
|
||||
};
|
||||
|
||||
const deleteWorkspace = async () => {
|
||||
deletingWs.value = true;
|
||||
wsError.value = '';
|
||||
try {
|
||||
const wsId = workspaceStore.currentWorkspace?.id;
|
||||
await workspaceStore.deleteWorkspace(wsId);
|
||||
showDeleteWorkspace.value = false;
|
||||
} catch (err) {
|
||||
wsError.value = err.message;
|
||||
} finally {
|
||||
deletingWs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
workspaceStore.clearAll();
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
@@ -227,4 +453,260 @@ const logout = () => {
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Workspace dropdown */
|
||||
.workspace-dropdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workspace-dropdown .workspace-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ws-action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ws-action-btn:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.workspace-info {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.plan-badge.free {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.plan-badge.pro {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.plan-badge.business {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-actions.split {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #991b1b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-danger-outline {
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.btn-danger-outline:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,12 +5,20 @@ import { useAuthStore } from '../stores/auth';
|
||||
import Landing from '../views/Landing.vue';
|
||||
import Login from '../views/auth/Login.vue';
|
||||
import Register from '../views/auth/Register.vue';
|
||||
import ForgotPassword from '../views/auth/ForgotPassword.vue';
|
||||
import ResetPassword from '../views/auth/ResetPassword.vue';
|
||||
import VerifyEmail from '../views/auth/VerifyEmail.vue';
|
||||
import Dashboard from '../views/dashboard/Dashboard.vue';
|
||||
import Links from '../views/links/Links.vue';
|
||||
import LinkDetail from '../views/links/LinkDetail.vue';
|
||||
import QRCodes from '../views/qrcodes/QRCodes.vue';
|
||||
import QRCodeDesigner from '../views/qrcodes/QRCodeDesigner.vue';
|
||||
import QRCodeDetail from '../views/qrcodes/QRCodeDetail.vue';
|
||||
import Analytics from '../views/analytics/Analytics.vue';
|
||||
import Billing from '../views/billing/Billing.vue';
|
||||
import Projects from '../views/projects/Projects.vue';
|
||||
import Domains from '../views/domains/Domains.vue';
|
||||
import Settings from '../views/settings/Settings.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -30,6 +38,23 @@ const routes = [
|
||||
component: Register,
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPassword,
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: ResetPassword,
|
||||
meta: { guest: true },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
name: 'verify-email',
|
||||
component: VerifyEmail,
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
@@ -66,12 +91,42 @@ const routes = [
|
||||
component: QRCodeDesigner,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/qrcodes/:id/analytics',
|
||||
name: 'qrcode-analytics',
|
||||
component: QRCodeDetail,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/analytics',
|
||||
name: 'analytics',
|
||||
component: Analytics,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/billing',
|
||||
name: 'billing',
|
||||
component: Billing,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'projects',
|
||||
component: Projects,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/domains',
|
||||
name: 'domains',
|
||||
component: Domains,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: Settings,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
token: localStorage.getItem('token'),
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -14,13 +15,30 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (this.token) {
|
||||
api.setToken(this.token);
|
||||
try {
|
||||
const profile = await api.getProfile();
|
||||
this.user = profile;
|
||||
} catch (err) {
|
||||
// Token is invalid, clear it
|
||||
this.logout();
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
},
|
||||
|
||||
async register(email, password) {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await api.register(email, password);
|
||||
this.token = response.token;
|
||||
this.user = { email: response.email };
|
||||
this.user = { email: response.email, isVerified: false };
|
||||
localStorage.setItem('token', response.token);
|
||||
api.setToken(response.token);
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -38,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
const response = await api.login(email, password);
|
||||
this.token = response.token;
|
||||
this.user = { email: response.email };
|
||||
localStorage.setItem('token', response.token);
|
||||
api.setToken(response.token);
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -51,15 +70,17 @@ export const useAuthStore = defineStore('auth', {
|
||||
logout() {
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('currentWorkspaceId');
|
||||
api.setToken(null);
|
||||
},
|
||||
|
||||
checkAuth() {
|
||||
if (this.token) {
|
||||
api.setToken(this.token);
|
||||
return true;
|
||||
async fetchProfile() {
|
||||
try {
|
||||
this.user = await api.getProfile();
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,23 +8,44 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
projects: [],
|
||||
links: [],
|
||||
qrcodes: [],
|
||||
domains: [],
|
||||
assets: [],
|
||||
analytics: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
currentWorkspaceId: (state) => state.currentWorkspace?.id,
|
||||
currentPlan: (state) => state.currentWorkspace?.plan || 'Free',
|
||||
canUseCustomDomains: (state) => {
|
||||
const plan = state.currentWorkspace?.plan;
|
||||
return plan === 'Pro' || plan === 'Business';
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
await this.fetchWorkspaces();
|
||||
this.initialized = true;
|
||||
},
|
||||
|
||||
async fetchWorkspaces() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await api.listWorkspaces();
|
||||
this.workspaces = response.workspaces;
|
||||
if (!this.currentWorkspace && this.workspaces.length > 0) {
|
||||
this.currentWorkspace = this.workspaces[0];
|
||||
this.workspaces = response.workspaces || [];
|
||||
|
||||
// Restore saved workspace or use first one
|
||||
const savedId = localStorage.getItem('currentWorkspaceId');
|
||||
const saved = savedId ? this.workspaces.find(w => w.id === savedId) : null;
|
||||
|
||||
if (saved) {
|
||||
this.currentWorkspace = saved;
|
||||
} else if (this.workspaces.length > 0) {
|
||||
this.setCurrentWorkspace(this.workspaces[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
@@ -35,9 +56,17 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
|
||||
setCurrentWorkspace(workspace) {
|
||||
this.currentWorkspace = workspace;
|
||||
if (workspace) {
|
||||
localStorage.setItem('currentWorkspaceId', workspace.id);
|
||||
} else {
|
||||
localStorage.removeItem('currentWorkspaceId');
|
||||
}
|
||||
// Clear workspace-specific data
|
||||
this.projects = [];
|
||||
this.links = [];
|
||||
this.qrcodes = [];
|
||||
this.domains = [];
|
||||
this.assets = [];
|
||||
this.analytics = null;
|
||||
},
|
||||
|
||||
@@ -52,12 +81,42 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
}
|
||||
},
|
||||
|
||||
async updateWorkspace(id, name) {
|
||||
try {
|
||||
const updated = await api.updateWorkspace(id, name);
|
||||
const index = this.workspaces.findIndex(w => w.id === id);
|
||||
if (index !== -1) {
|
||||
this.workspaces[index] = updated;
|
||||
}
|
||||
if (this.currentWorkspace?.id === id) {
|
||||
this.currentWorkspace = updated;
|
||||
}
|
||||
return updated;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkspace(id) {
|
||||
try {
|
||||
await api.deleteWorkspace(id);
|
||||
this.workspaces = this.workspaces.filter(w => w.id !== id);
|
||||
if (this.currentWorkspace?.id === id) {
|
||||
this.setCurrentWorkspace(this.workspaces[0] || null);
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Projects
|
||||
async fetchProjects() {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const response = await api.listProjects(this.currentWorkspaceId);
|
||||
this.projects = response.projects;
|
||||
this.projects = response.projects || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
@@ -67,7 +126,22 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const project = await api.createProject(this.currentWorkspaceId, name, description);
|
||||
this.projects.push(project);
|
||||
this.projects.unshift(project);
|
||||
return project;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async updateProject(id, data) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const project = await api.updateProject(this.currentWorkspaceId, id, data);
|
||||
const index = this.projects.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
this.projects[index] = project;
|
||||
}
|
||||
return project;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
@@ -91,7 +165,7 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const response = await api.listLinks(this.currentWorkspaceId, params);
|
||||
this.links = response.links;
|
||||
this.links = response.links || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
@@ -140,7 +214,7 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const response = await api.listQRCodes(this.currentWorkspaceId);
|
||||
this.qrcodes = response.qrCodes;
|
||||
this.qrcodes = response.qrCodes || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
@@ -184,14 +258,109 @@ export const useWorkspaceStore = defineStore('workspace', {
|
||||
}
|
||||
},
|
||||
|
||||
// Analytics
|
||||
async fetchAnalytics(period = '7d') {
|
||||
// Domains
|
||||
async fetchDomains() {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period);
|
||||
const response = await api.listDomains(this.currentWorkspaceId);
|
||||
this.domains = response.domains || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
},
|
||||
|
||||
async addDomain(hostname) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const domain = await api.addDomain(this.currentWorkspaceId, hostname);
|
||||
this.domains.unshift(domain);
|
||||
return domain;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async verifyDomain(id) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const result = await api.verifyDomain(this.currentWorkspaceId, id);
|
||||
// Refresh domains to get updated status
|
||||
await this.fetchDomains();
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDomain(id) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
await api.deleteDomain(this.currentWorkspaceId, id);
|
||||
this.domains = this.domains.filter(d => d.id !== id);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Assets
|
||||
async fetchAssets() {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const response = await api.listAssets(this.currentWorkspaceId);
|
||||
this.assets = response.assets || [];
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
},
|
||||
|
||||
async uploadAsset(file) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
const asset = await api.uploadAsset(this.currentWorkspaceId, file);
|
||||
this.assets.unshift(asset);
|
||||
return asset;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAsset(id) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
await api.deleteAsset(this.currentWorkspaceId, id);
|
||||
this.assets = this.assets.filter(a => a.id !== id);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
// Analytics
|
||||
async fetchAnalytics(period = '7d', startDate = null, endDate = null) {
|
||||
if (!this.currentWorkspaceId) return;
|
||||
try {
|
||||
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period, startDate, endDate);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear all data (for logout)
|
||||
clearAll() {
|
||||
this.workspaces = [];
|
||||
this.currentWorkspace = null;
|
||||
this.projects = [];
|
||||
this.links = [];
|
||||
this.qrcodes = [];
|
||||
this.domains = [];
|
||||
this.assets = [];
|
||||
this.analytics = null;
|
||||
this.initialized = false;
|
||||
localStorage.removeItem('currentWorkspaceId');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,15 +6,28 @@
|
||||
<h1>Analytics</h1>
|
||||
<p class="subtitle">Track performance across your workspace</p>
|
||||
</div>
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="{ active: period === p.value }"
|
||||
@click="setPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
<div class="period-controls">
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="{ active: period === p.value && !isCustomRange }"
|
||||
@click="setPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: isCustomRange }"
|
||||
@click="toggleCustomRange"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isCustomRange" class="date-range">
|
||||
<input type="date" v-model="startDate" @change="applyCustomRange" />
|
||||
<span>to</span>
|
||||
<input type="date" v-model="endDate" @change="applyCustomRange" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -198,13 +211,34 @@ const periods = [
|
||||
];
|
||||
|
||||
const period = ref('7d');
|
||||
const isCustomRange = ref(false);
|
||||
const startDate = ref('');
|
||||
const endDate = ref('');
|
||||
const analytics = computed(() => workspaceStore.analytics);
|
||||
|
||||
const setPeriod = async (p) => {
|
||||
isCustomRange.value = false;
|
||||
period.value = p;
|
||||
await workspaceStore.fetchAnalytics(p);
|
||||
};
|
||||
|
||||
const toggleCustomRange = () => {
|
||||
isCustomRange.value = true;
|
||||
// Set default range to last 30 days
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 30);
|
||||
startDate.value = start.toISOString().split('T')[0];
|
||||
endDate.value = end.toISOString().split('T')[0];
|
||||
applyCustomRange();
|
||||
};
|
||||
|
||||
const applyCustomRange = async () => {
|
||||
if (startDate.value && endDate.value) {
|
||||
await workspaceStore.fetchAnalytics(null, startDate.value, endDate.value);
|
||||
}
|
||||
};
|
||||
|
||||
const maxEvents = computed(() => {
|
||||
if (!analytics.value?.timeSeries) return 1;
|
||||
return Math.max(...analytics.value.timeSeries.map(p => Math.max(p.clicks, p.scans)), 1);
|
||||
@@ -264,6 +298,13 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.period-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -290,6 +331,36 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.date-range input[type="date"] {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.date-range input[type="date"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.date-range span {
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
||||
215
src/frontend/src/views/auth/ForgotPassword.vue
Normal file
215
src/frontend/src/views/auth/ForgotPassword.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">TQ</span>
|
||||
<span class="brand-name">TrakQR</span>
|
||||
</div>
|
||||
<h1>Reset your password</h1>
|
||||
<p>Enter your email and we'll send you a reset link</p>
|
||||
</div>
|
||||
|
||||
<form v-if="!submitted" @submit.prevent="handleSubmit" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="cta full" :disabled="loading">
|
||||
{{ loading ? 'Sending...' : 'Send reset link' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="success-message">
|
||||
<div class="success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Check your email</h2>
|
||||
<p>If an account exists for {{ email }}, we've sent password reset instructions.</p>
|
||||
<router-link to="/login" class="cta full">Back to login</router-link>
|
||||
</div>
|
||||
|
||||
<p class="auth-footer" v-if="!submitted">
|
||||
Remember your password?
|
||||
<router-link to="/login">Sign in</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
const email = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const submitted = ref(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await api.forgotPassword(email.value);
|
||||
submitted.value = true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--ink);
|
||||
color: #fff4ec;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 10px;
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #22c55e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-message h2 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cta.full {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-footer a {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -37,6 +37,10 @@
|
||||
{{ authStore.error }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<router-link to="/forgot-password" class="forgot-link">Forgot password?</router-link>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="cta full" :disabled="authStore.loading">
|
||||
{{ authStore.loading ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
@@ -167,6 +171,19 @@ const handleSubmit = async () => {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cta.full {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
|
||||
231
src/frontend/src/views/auth/ResetPassword.vue
Normal file
231
src/frontend/src/views/auth/ResetPassword.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">TQ</span>
|
||||
<span class="brand-name">TrakQR</span>
|
||||
</div>
|
||||
<h1>Set new password</h1>
|
||||
<p>Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
<form v-if="!success" @submit.prevent="handleSubmit" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="password">New Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="cta full" :disabled="loading || !isValid">
|
||||
{{ loading ? 'Resetting...' : 'Reset password' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="success-message">
|
||||
<div class="success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Password reset complete</h2>
|
||||
<p>Your password has been updated. You can now sign in with your new password.</p>
|
||||
<router-link to="/login" class="cta full">Sign in</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref(false);
|
||||
|
||||
const token = computed(() => route.query.token || '');
|
||||
|
||||
const isValid = computed(() => {
|
||||
return password.value.length >= 8 && password.value === confirmPassword.value;
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!token.value) {
|
||||
error.value = 'Invalid reset link. Please request a new one.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await api.resetPassword(token.value, password.value);
|
||||
success.value = true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--ink);
|
||||
color: #fff4ec;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 10px;
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #22c55e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-message h2 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cta.full {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
244
src/frontend/src/views/auth/VerifyEmail.vue
Normal file
244
src/frontend/src/views/auth/VerifyEmail.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">TQ</span>
|
||||
<span class="brand-name">TrakQR</span>
|
||||
</div>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="status-message loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Verifying your email...</p>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div v-else-if="success" class="status-message success">
|
||||
<div class="status-icon success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Email verified!</h2>
|
||||
<p>Your email has been verified successfully. You can now access all features.</p>
|
||||
<router-link to="/dashboard" class="cta full">Go to Dashboard</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="status-message error">
|
||||
<div class="status-icon error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Verification failed</h2>
|
||||
<p>{{ error }}</p>
|
||||
<div class="action-buttons">
|
||||
<router-link to="/settings" class="cta full">Request new link</router-link>
|
||||
<router-link to="/login" class="link-secondary">Back to login</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No token state -->
|
||||
<div v-else class="status-message error">
|
||||
<div class="status-icon error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Invalid link</h2>
|
||||
<p>This verification link is invalid or missing. Please check your email for the correct link.</p>
|
||||
<div class="action-buttons">
|
||||
<router-link to="/settings" class="cta full">Request new link</router-link>
|
||||
<router-link to="/login" class="link-secondary">Back to login</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const success = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const token = computed(() => route.query.token || '');
|
||||
|
||||
const title = computed(() => {
|
||||
if (loading.value) return 'Verifying email';
|
||||
if (success.value) return 'Email verified';
|
||||
if (error.value || !token.value) return 'Verification failed';
|
||||
return 'Verify your email';
|
||||
});
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (loading.value) return 'Please wait while we verify your email address';
|
||||
if (success.value) return 'Your account is now fully activated';
|
||||
return 'There was a problem with your verification';
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (!token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await api.verifyEmail(token.value);
|
||||
success.value = true;
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Unable to verify your email. The link may be invalid or expired.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--surface);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--ink);
|
||||
color: #fff4ec;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-message.loading {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-message.loading p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-message h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-message p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cta.full {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-secondary {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.link-secondary:hover {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
598
src/frontend/src/views/billing/Billing.vue
Normal file
598
src/frontend/src/views/billing/Billing.vue
Normal file
@@ -0,0 +1,598 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="billing">
|
||||
<header class="page-header">
|
||||
<h1>Billing & Plans</h1>
|
||||
<p class="subtitle">Manage your subscription and view usage</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Current Plan Card -->
|
||||
<section class="card current-plan">
|
||||
<div class="plan-header">
|
||||
<div>
|
||||
<h2>Current Plan</h2>
|
||||
<div class="plan-badge" :class="currentPlan.toLowerCase()">
|
||||
{{ currentPlan }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="subscription?.subscriptionId"
|
||||
class="btn btn-secondary"
|
||||
@click="openPortal"
|
||||
:disabled="portalLoading"
|
||||
>
|
||||
{{ portalLoading ? 'Loading...' : 'Manage Subscription' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription?.cancelAtPeriodEnd" class="cancel-notice">
|
||||
Your subscription will be canceled on {{ formatDate(subscription.currentPeriodEnd) }}.
|
||||
You'll be downgraded to the Free plan after this date.
|
||||
</div>
|
||||
|
||||
<div class="usage-section" v-if="usage">
|
||||
<h3>Usage This Month</h3>
|
||||
<div class="usage-grid">
|
||||
<div class="usage-item">
|
||||
<div class="usage-bar">
|
||||
<div
|
||||
class="usage-fill"
|
||||
:style="{ width: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) + '%' }"
|
||||
:class="{ warning: getUsagePercent(usage.workspaces, usage.limits.maxWorkspaces) > 80 }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="usage-label">
|
||||
<span>Workspaces</span>
|
||||
<span>{{ usage.workspaces }} / {{ formatLimit(usage.limits.maxWorkspaces) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-item">
|
||||
<div class="usage-bar">
|
||||
<div
|
||||
class="usage-fill"
|
||||
:style="{ width: getUsagePercent(usage.links, usage.limits.maxLinks) + '%' }"
|
||||
:class="{ warning: getUsagePercent(usage.links, usage.limits.maxLinks) > 80 }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="usage-label">
|
||||
<span>Links</span>
|
||||
<span>{{ usage.links }} / {{ formatLimit(usage.limits.maxLinks) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-item">
|
||||
<div class="usage-bar">
|
||||
<div
|
||||
class="usage-fill"
|
||||
:style="{ width: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) + '%' }"
|
||||
:class="{ warning: getUsagePercent(usage.qrCodes, usage.limits.maxQRCodes) > 80 }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="usage-label">
|
||||
<span>QR Codes</span>
|
||||
<span>{{ usage.qrCodes }} / {{ formatLimit(usage.limits.maxQRCodes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-item">
|
||||
<div class="usage-bar">
|
||||
<div
|
||||
class="usage-fill"
|
||||
:style="{ width: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) + '%' }"
|
||||
:class="{ warning: getUsagePercent(usage.eventsThisMonth, usage.limits.maxEventsPerMonth) > 80 }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="usage-label">
|
||||
<span>Events</span>
|
||||
<span>{{ formatNumber(usage.eventsThisMonth) }} / {{ formatLimit(usage.limits.maxEventsPerMonth) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Plan Comparison -->
|
||||
<section class="plans-section">
|
||||
<h2>Available Plans</h2>
|
||||
<div class="plans-grid">
|
||||
<div class="plan-card" :class="{ current: currentPlan === 'Free' }">
|
||||
<div class="plan-name">Free</div>
|
||||
<div class="plan-price">
|
||||
<span class="amount">$0</span>
|
||||
<span class="period">/month</span>
|
||||
</div>
|
||||
<ul class="plan-features">
|
||||
<li>1 workspace</li>
|
||||
<li>50 links</li>
|
||||
<li>25 QR codes</li>
|
||||
<li>10,000 events/month</li>
|
||||
<li>Basic analytics</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="currentPlan === 'Free'"
|
||||
class="btn btn-secondary"
|
||||
disabled
|
||||
>
|
||||
Current Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="plan-card featured" :class="{ current: currentPlan === 'Pro' }">
|
||||
<div class="popular-badge">Most Popular</div>
|
||||
<div class="plan-name">Pro</div>
|
||||
<div class="plan-price">
|
||||
<span class="amount">$29</span>
|
||||
<span class="period">/month</span>
|
||||
</div>
|
||||
<ul class="plan-features">
|
||||
<li>5 workspaces</li>
|
||||
<li>5,000 links</li>
|
||||
<li>1,000 QR codes</li>
|
||||
<li>100,000 events/month</li>
|
||||
<li>3 custom domains</li>
|
||||
<li>Password protection</li>
|
||||
<li>Advanced analytics</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="currentPlan === 'Pro'"
|
||||
class="btn btn-secondary"
|
||||
disabled
|
||||
>
|
||||
Current Plan
|
||||
</button>
|
||||
<button
|
||||
v-else-if="currentPlan === 'Free'"
|
||||
class="btn btn-primary"
|
||||
@click="upgrade('Pro')"
|
||||
:disabled="upgrading"
|
||||
>
|
||||
{{ upgrading ? 'Loading...' : 'Upgrade to Pro' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-secondary"
|
||||
@click="openPortal"
|
||||
>
|
||||
Downgrade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="plan-card" :class="{ current: currentPlan === 'Business' }">
|
||||
<div class="plan-name">Business</div>
|
||||
<div class="plan-price">
|
||||
<span class="amount">$99</span>
|
||||
<span class="period">/month</span>
|
||||
</div>
|
||||
<ul class="plan-features">
|
||||
<li>Unlimited workspaces</li>
|
||||
<li>Unlimited links</li>
|
||||
<li>Unlimited QR codes</li>
|
||||
<li>Unlimited events</li>
|
||||
<li>Unlimited custom domains</li>
|
||||
<li>Password protection</li>
|
||||
<li>Priority support</li>
|
||||
<li>API access</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="currentPlan === 'Business'"
|
||||
class="btn btn-secondary"
|
||||
disabled
|
||||
>
|
||||
Current Plan
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-primary"
|
||||
@click="upgrade('Business')"
|
||||
:disabled="upgrading"
|
||||
>
|
||||
{{ upgrading ? 'Loading...' : 'Upgrade to Business' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api } from '../../api/client';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
import AppLayout from '../../components/layout/AppLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const upgrading = ref(false);
|
||||
const portalLoading = ref(false);
|
||||
const error = ref('');
|
||||
const usage = ref(null);
|
||||
const subscription = ref(null);
|
||||
|
||||
const currentPlan = computed(() => usage.value?.plan || 'Free');
|
||||
|
||||
onMounted(async () => {
|
||||
// Check for success from checkout
|
||||
if (route.query.session_id) {
|
||||
// Payment was successful, refresh data
|
||||
setTimeout(() => loadData(), 1000);
|
||||
} else {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const workspaceId = workspaceStore.currentWorkspace?.id;
|
||||
|
||||
const [usageData, subData] = await Promise.all([
|
||||
api.getUsage(workspaceId),
|
||||
workspaceId ? api.getSubscription(workspaceId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
usage.value = usageData;
|
||||
subscription.value = subData;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function upgrade(plan) {
|
||||
upgrading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const workspaceId = workspaceStore.currentWorkspace?.id;
|
||||
if (!workspaceId) {
|
||||
throw new Error('No workspace selected');
|
||||
}
|
||||
|
||||
const successUrl = window.location.origin + '/billing';
|
||||
const cancelUrl = window.location.origin + '/billing';
|
||||
|
||||
const { url } = await api.createCheckoutSession(
|
||||
workspaceId,
|
||||
plan,
|
||||
successUrl,
|
||||
cancelUrl
|
||||
);
|
||||
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
upgrading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPortal() {
|
||||
portalLoading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const returnUrl = window.location.origin + '/billing';
|
||||
const { url } = await api.createPortalSession(returnUrl);
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
portalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getUsagePercent(used, limit) {
|
||||
if (limit === 2147483647 || limit === -1) return 0; // Unlimited
|
||||
return Math.min(100, (used / limit) * 100);
|
||||
}
|
||||
|
||||
function formatLimit(limit) {
|
||||
if (limit === 2147483647 || limit === -1) return 'Unlimited';
|
||||
return formatNumber(limit);
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.billing {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.current-plan .plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.current-plan h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-badge.free {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.plan-badge.pro {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.plan-badge.business {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.cancel-notice {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.usage-section h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.usage-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.usage-bar {
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.usage-fill.warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.usage-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.plans-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.plans-section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.plans-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
background: white;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.plan-card:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.plan-card.featured {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.plan-card.current {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.popular-badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.plan-price .amount {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plan-price .period {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.plan-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.plan-features li {
|
||||
padding: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.plan-features li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2310b981'%3E%3Cpath fill-rule='evenodd' d='M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z' clip-rule='evenodd'/%3E%3C/svg%3E") no-repeat center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #dc2626;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.billing {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.current-plan .plan-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plans-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -145,6 +145,26 @@
|
||||
<p>No referrer data yet</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Top Countries</h2>
|
||||
</div>
|
||||
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
|
||||
<div v-for="country in analytics.countryBreakdown.slice(0, 5)" :key="country.key" class="breakdown-item">
|
||||
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
|
||||
<span class="breakdown-label">
|
||||
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
|
||||
{{ getCountryName(country.key) }}
|
||||
</span>
|
||||
<span class="breakdown-value">{{ country.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No geographic data yet</p>
|
||||
<p class="hint">Country detection requires a GeoIP database</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -189,6 +209,52 @@ const getPercentage = (value) => {
|
||||
return Math.max((value / totalEvents.value) * 100, 5);
|
||||
};
|
||||
|
||||
// Country code to flag emoji converter
|
||||
const getCountryFlag = (countryCode) => {
|
||||
if (!countryCode || countryCode.length !== 2) return '';
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
// Country code to name mapping (common codes)
|
||||
const countryNames = {
|
||||
US: 'United States',
|
||||
GB: 'United Kingdom',
|
||||
CA: 'Canada',
|
||||
AU: 'Australia',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
JP: 'Japan',
|
||||
CN: 'China',
|
||||
IN: 'India',
|
||||
BR: 'Brazil',
|
||||
MX: 'Mexico',
|
||||
ES: 'Spain',
|
||||
IT: 'Italy',
|
||||
NL: 'Netherlands',
|
||||
SE: 'Sweden',
|
||||
NO: 'Norway',
|
||||
DK: 'Denmark',
|
||||
FI: 'Finland',
|
||||
PL: 'Poland',
|
||||
RU: 'Russia',
|
||||
KR: 'South Korea',
|
||||
SG: 'Singapore',
|
||||
NZ: 'New Zealand',
|
||||
IE: 'Ireland',
|
||||
CH: 'Switzerland',
|
||||
AT: 'Austria',
|
||||
BE: 'Belgium',
|
||||
PT: 'Portugal',
|
||||
};
|
||||
|
||||
const getCountryName = (countryCode) => {
|
||||
return countryNames[countryCode] || countryCode;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await workspaceStore.fetchLinks();
|
||||
await workspaceStore.fetchAnalytics(period.value);
|
||||
@@ -417,10 +483,24 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-state .cta {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.country-bar {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.country-flag {
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.cta.small {
|
||||
padding: 10px 16px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
761
src/frontend/src/views/domains/Domains.vue
Normal file
761
src/frontend/src/views/domains/Domains.vue
Normal file
@@ -0,0 +1,761 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="domains">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Custom Domains</h1>
|
||||
<p class="subtitle">Use your own domain for branded short links</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="showAddModal = true" :disabled="!canAddDomain">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Domain
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="!canAddDomain" class="upgrade-banner">
|
||||
<div class="banner-content">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Custom domains require a Pro or Business plan.</strong>
|
||||
<p>Upgrade to use your own branded domains for short links.</p>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/billing" class="btn btn-primary">Upgrade</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading domains...</div>
|
||||
|
||||
<div v-else-if="domains.length === 0 && canAddDomain" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No custom domains yet</h3>
|
||||
<p>Add your own domain to create branded short links.</p>
|
||||
<button class="btn btn-primary" @click="showAddModal = true">Add Domain</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="domains.length > 0" class="domains-list">
|
||||
<div v-for="domain in domains" :key="domain.id" class="domain-card">
|
||||
<div class="domain-info">
|
||||
<div class="domain-status" :class="domain.status.toLowerCase()">
|
||||
{{ domain.status }}
|
||||
</div>
|
||||
<h3 class="domain-name">{{ domain.hostname }}</h3>
|
||||
<p class="domain-date">Added {{ formatDate(domain.createdAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="domain-actions">
|
||||
<template v-if="domain.status === 'Pending'">
|
||||
<button class="btn btn-secondary" @click="showVerifyInstructions(domain)">
|
||||
Verify Domain
|
||||
</button>
|
||||
</template>
|
||||
<button class="icon-btn danger" @click="confirmDelete(domain)" title="Delete">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Add Custom Domain</h2>
|
||||
<button class="close-btn" @click="showAddModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="addDomain">
|
||||
<div class="form-group">
|
||||
<label for="hostname">Domain</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="newDomain"
|
||||
type="text"
|
||||
placeholder="links.yourdomain.com"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<p class="form-hint">Enter the subdomain or domain you want to use for short links.</p>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="adding">
|
||||
{{ adding ? 'Adding...' : 'Add Domain' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Instructions Modal -->
|
||||
<div v-if="showVerifyModal" class="modal-overlay" @click.self="showVerifyModal = false">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h2>Verify {{ verifyingDomain?.hostname }}</h2>
|
||||
<button class="close-btn" @click="showVerifyModal = false">×</button>
|
||||
</div>
|
||||
<div class="verify-content">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-content">
|
||||
<h4>Add DNS TXT Record</h4>
|
||||
<p>Add the following TXT record to your domain's DNS settings:</p>
|
||||
<div class="dns-record">
|
||||
<div class="record-row">
|
||||
<span class="record-label">Type:</span>
|
||||
<span class="record-value">TXT</span>
|
||||
</div>
|
||||
<div class="record-row">
|
||||
<span class="record-label">Host/Name:</span>
|
||||
<span class="record-value">_trakqr-verification</span>
|
||||
</div>
|
||||
<div class="record-row">
|
||||
<span class="record-label">Value:</span>
|
||||
<div class="record-value token">
|
||||
{{ verifyingDomain?.verificationToken }}
|
||||
<button class="copy-btn" @click="copyToken" title="Copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-content">
|
||||
<h4>Add CNAME Record</h4>
|
||||
<p>Point your domain to our servers:</p>
|
||||
<div class="dns-record">
|
||||
<div class="record-row">
|
||||
<span class="record-label">Type:</span>
|
||||
<span class="record-value">CNAME</span>
|
||||
</div>
|
||||
<div class="record-row">
|
||||
<span class="record-label">Host/Name:</span>
|
||||
<span class="record-value">{{ verifyingDomain?.hostname }}</span>
|
||||
</div>
|
||||
<div class="record-row">
|
||||
<span class="record-label">Value:</span>
|
||||
<span class="record-value">cname.trakqr.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-content">
|
||||
<h4>Verify Ownership</h4>
|
||||
<p>DNS changes can take up to 48 hours to propagate. Once ready, click verify.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="verifyError" class="error-message">{{ verifyError }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showVerifyModal = false">Close</button>
|
||||
<button class="btn btn-primary" @click="verifyDomain" :disabled="verifying">
|
||||
{{ verifying ? 'Verifying...' : 'Verify Domain' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2>Delete Domain</h2>
|
||||
<button class="close-btn" @click="showDeleteModal = false">×</button>
|
||||
</div>
|
||||
<p class="delete-warning">
|
||||
Are you sure you want to delete <strong>{{ domainToDelete?.hostname }}</strong>?
|
||||
Links using this domain will stop working.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteDomain" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
import AppLayout from '../../components/layout/AppLayout.vue';
|
||||
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const domains = computed(() => workspaceStore.domains);
|
||||
const loading = ref(true);
|
||||
const adding = ref(false);
|
||||
const verifying = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref('');
|
||||
const verifyError = ref('');
|
||||
|
||||
const showAddModal = ref(false);
|
||||
const showVerifyModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
|
||||
const newDomain = ref('');
|
||||
const verifyingDomain = ref(null);
|
||||
const domainToDelete = ref(null);
|
||||
|
||||
const canAddDomain = computed(() => workspaceStore.canUseCustomDomains);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDomains();
|
||||
});
|
||||
|
||||
watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
if (workspaceStore.currentWorkspaceId) {
|
||||
await loadDomains();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDomains() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await workspaceStore.fetchDomains();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addDomain() {
|
||||
adding.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const domain = await workspaceStore.addDomain(newDomain.value);
|
||||
showAddModal.value = false;
|
||||
newDomain.value = '';
|
||||
// Show verification instructions for the new domain
|
||||
verifyingDomain.value = domain;
|
||||
showVerifyModal.value = true;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showVerifyInstructions(domain) {
|
||||
verifyingDomain.value = domain;
|
||||
verifyError.value = '';
|
||||
showVerifyModal.value = true;
|
||||
}
|
||||
|
||||
async function verifyDomain() {
|
||||
verifying.value = true;
|
||||
verifyError.value = '';
|
||||
try {
|
||||
await workspaceStore.verifyDomain(verifyingDomain.value.id);
|
||||
showVerifyModal.value = false;
|
||||
verifyingDomain.value = null;
|
||||
} catch (err) {
|
||||
verifyError.value = err.message;
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(domain) {
|
||||
domainToDelete.value = domain;
|
||||
showDeleteModal.value = true;
|
||||
}
|
||||
|
||||
async function deleteDomain() {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await workspaceStore.deleteDomain(domainToDelete.value.id);
|
||||
showDeleteModal.value = false;
|
||||
domainToDelete.value = null;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
navigator.clipboard.writeText(verifyingDomain.value?.verificationToken);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.domains {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.banner-content svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.banner-content strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.banner-content p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: #d1d5db;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.domains-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.domain-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.domain-status {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.domain-status.pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.domain-status.verified {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.domain-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.domain-date {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.domain-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-lg {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.verify-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.step:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-content h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.dns-record {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.record-row {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.record-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.record-label {
|
||||
width: 100px;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.record-value {
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-value.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
padding: 1.5rem;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upgrade-banner {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.domain-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -97,6 +97,23 @@
|
||||
<p>No referrer data yet</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Countries</h2>
|
||||
<div class="breakdown" v-if="analytics?.countryBreakdown?.length">
|
||||
<div v-for="country in analytics.countryBreakdown" :key="country.key" class="breakdown-item">
|
||||
<div class="breakdown-bar country-bar" :style="{ width: getPercentage(country.count) + '%' }"></div>
|
||||
<span class="breakdown-label">
|
||||
<span class="country-flag">{{ getCountryFlag(country.key) }}</span>
|
||||
{{ getCountryName(country.key) }}
|
||||
</span>
|
||||
<span class="breakdown-value">{{ country.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No geographic data yet</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card link-info-card">
|
||||
@@ -211,6 +228,28 @@ const copyToClipboard = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Country code to flag emoji converter
|
||||
const getCountryFlag = (countryCode) => {
|
||||
if (!countryCode || countryCode.length !== 2) return '';
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
// Country code to name mapping
|
||||
const countryNames = {
|
||||
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
|
||||
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
|
||||
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands',
|
||||
SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland',
|
||||
RU: 'Russia', KR: 'South Korea', SG: 'Singapore', NZ: 'New Zealand',
|
||||
IE: 'Ireland', CH: 'Switzerland', AT: 'Austria', BE: 'Belgium', PT: 'Portugal',
|
||||
};
|
||||
|
||||
const getCountryName = (countryCode) => countryNames[countryCode] || countryCode;
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
});
|
||||
@@ -440,6 +479,15 @@ onMounted(async () => {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.country-bar {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.country-flag {
|
||||
margin-right: 8px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
|
||||
@@ -6,13 +6,35 @@
|
||||
<h1>Links</h1>
|
||||
<p class="subtitle">Manage your short links</p>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="cta">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Create Link
|
||||
</button>
|
||||
<div class="view-toggle">
|
||||
<button :class="{ active: !showDeleted }" @click="showDeleted = false">
|
||||
Active
|
||||
</button>
|
||||
<button :class="{ active: showDeleted }" @click="toggleDeleted">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
Trash
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button @click="showBulkModal = true" class="ghost">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Bulk Import
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="cta">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Create Link
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="links-list" v-if="workspaceStore.links.length">
|
||||
@@ -125,6 +147,60 @@
|
||||
<p class="hint">Leave empty for auto-generated slug</p>
|
||||
</div>
|
||||
|
||||
<!-- UTM Builder -->
|
||||
<div class="utm-section">
|
||||
<button type="button" class="utm-toggle" @click="showUtmBuilder = !showUtmBuilder">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
{{ showUtmBuilder ? 'Hide' : 'Add' }} UTM Parameters
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ rotated: showUtmBuilder }">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="showUtmBuilder" class="utm-fields">
|
||||
<div class="utm-presets">
|
||||
<span class="utm-preset-label">Presets:</span>
|
||||
<button type="button" @click="applyUtmPreset('google')" class="utm-preset-btn">Google Ads</button>
|
||||
<button type="button" @click="applyUtmPreset('facebook')" class="utm-preset-btn">Facebook</button>
|
||||
<button type="button" @click="applyUtmPreset('email')" class="utm-preset-btn">Email</button>
|
||||
<button type="button" @click="applyUtmPreset('social')" class="utm-preset-btn">Social</button>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="utm_source">Source</label>
|
||||
<input id="utm_source" v-model="utmParams.source" type="text" placeholder="google, facebook, newsletter" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="utm_medium">Medium</label>
|
||||
<input id="utm_medium" v-model="utmParams.medium" type="text" placeholder="cpc, social, email" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="utm_campaign">Campaign</label>
|
||||
<input id="utm_campaign" v-model="utmParams.campaign" type="text" placeholder="summer_sale, product_launch" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="utm_term">Term (optional)</label>
|
||||
<input id="utm_term" v-model="utmParams.term" type="text" placeholder="keywords" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="utm_content">Content (optional)</label>
|
||||
<input id="utm_content" v-model="utmParams.content" type="text" placeholder="banner, textlink" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="utmPreview" class="utm-preview">
|
||||
<strong>Preview:</strong> {{ utmPreview }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="expiresAt">Expires (optional)</label>
|
||||
@@ -171,24 +247,91 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Import Modal -->
|
||||
<div v-if="showBulkModal" class="modal-overlay" @click.self="closeBulkModal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<h2>Bulk Import Links</h2>
|
||||
<button @click="closeBulkModal" class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="bulk-content">
|
||||
<p class="bulk-instructions">Paste URLs below, one per line. You can optionally add a title after the URL separated by a comma.</p>
|
||||
<p class="bulk-example">Example: https://example.com, My Link Title</p>
|
||||
|
||||
<textarea
|
||||
v-model="bulkUrls"
|
||||
placeholder="https://example.com https://another-site.com, My Page https://third-url.com"
|
||||
rows="10"
|
||||
class="bulk-textarea"
|
||||
></textarea>
|
||||
|
||||
<div class="bulk-stats">
|
||||
<span>{{ parsedBulkLinks.length }} URLs detected</span>
|
||||
</div>
|
||||
|
||||
<div v-if="bulkError" class="error-message">{{ bulkError }}</div>
|
||||
|
||||
<div v-if="bulkResults" class="bulk-results">
|
||||
<div v-if="bulkResults.created.length" class="bulk-success">
|
||||
<strong>{{ bulkResults.created.length }}</strong> links created successfully
|
||||
</div>
|
||||
<div v-if="bulkResults.errors.length" class="bulk-errors">
|
||||
<strong>{{ bulkResults.errors.length }}</strong> errors:
|
||||
<ul>
|
||||
<li v-for="(err, i) in bulkResults.errors.slice(0, 5)" :key="i">
|
||||
{{ err.url }}: {{ err.error }}
|
||||
</li>
|
||||
<li v-if="bulkResults.errors.length > 5">
|
||||
...and {{ bulkResults.errors.length - 5 }} more
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button @click="closeBulkModal" class="ghost">{{ bulkResults ? 'Close' : 'Cancel' }}</button>
|
||||
<button
|
||||
v-if="!bulkResults"
|
||||
@click="importBulkLinks"
|
||||
class="cta"
|
||||
:disabled="bulkImporting || parsedBulkLinks.length === 0"
|
||||
>
|
||||
{{ bulkImporting ? 'Importing...' : `Import ${parsedBulkLinks.length} Links` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import AppLayout from '../../components/layout/AppLayout.vue';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const showBulkModal = ref(false);
|
||||
const showUtmBuilder = ref(false);
|
||||
const showDeleted = ref(false);
|
||||
const editingLink = ref(null);
|
||||
const deletingLink = ref(null);
|
||||
const saving = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// Bulk import state
|
||||
const bulkUrls = ref('');
|
||||
const bulkImporting = ref(false);
|
||||
const bulkError = ref('');
|
||||
const bulkResults = ref(null);
|
||||
|
||||
const formData = ref({
|
||||
destinationUrl: '',
|
||||
title: '',
|
||||
@@ -197,6 +340,44 @@ const formData = ref({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const utmParams = ref({
|
||||
source: '',
|
||||
medium: '',
|
||||
campaign: '',
|
||||
term: '',
|
||||
content: '',
|
||||
});
|
||||
|
||||
const utmPresets = {
|
||||
google: { source: 'google', medium: 'cpc', campaign: '' },
|
||||
facebook: { source: 'facebook', medium: 'social', campaign: '' },
|
||||
email: { source: 'newsletter', medium: 'email', campaign: '' },
|
||||
social: { source: 'twitter', medium: 'social', campaign: '' },
|
||||
};
|
||||
|
||||
const applyUtmPreset = (preset) => {
|
||||
const p = utmPresets[preset];
|
||||
if (p) {
|
||||
utmParams.value = { ...utmParams.value, ...p };
|
||||
}
|
||||
};
|
||||
|
||||
const utmPreview = computed(() => {
|
||||
const params = [];
|
||||
if (utmParams.value.source) params.push(`utm_source=${utmParams.value.source}`);
|
||||
if (utmParams.value.medium) params.push(`utm_medium=${utmParams.value.medium}`);
|
||||
if (utmParams.value.campaign) params.push(`utm_campaign=${utmParams.value.campaign}`);
|
||||
if (utmParams.value.term) params.push(`utm_term=${utmParams.value.term}`);
|
||||
if (utmParams.value.content) params.push(`utm_content=${utmParams.value.content}`);
|
||||
return params.length ? '?' + params.join('&') : '';
|
||||
});
|
||||
|
||||
const buildUrlWithUtm = (baseUrl) => {
|
||||
if (!utmPreview.value) return baseUrl;
|
||||
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||
return baseUrl + separator + utmPreview.value.substring(1);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
destinationUrl: '',
|
||||
@@ -205,6 +386,14 @@ const resetForm = () => {
|
||||
expiresAt: '',
|
||||
password: '',
|
||||
};
|
||||
utmParams.value = {
|
||||
source: '',
|
||||
medium: '',
|
||||
campaign: '',
|
||||
term: '',
|
||||
content: '',
|
||||
};
|
||||
showUtmBuilder.value = false;
|
||||
formError.value = '';
|
||||
editingLink.value = null;
|
||||
};
|
||||
@@ -231,8 +420,11 @@ const saveLink = async () => {
|
||||
formError.value = '';
|
||||
|
||||
try {
|
||||
// Build destination URL with UTM parameters
|
||||
const finalUrl = buildUrlWithUtm(formData.value.destinationUrl);
|
||||
|
||||
const data = {
|
||||
destinationUrl: formData.value.destinationUrl,
|
||||
destinationUrl: finalUrl,
|
||||
title: formData.value.title || null,
|
||||
expiresAt: formData.value.expiresAt ? new Date(formData.value.expiresAt).toISOString() : null,
|
||||
password: formData.value.password || null,
|
||||
@@ -287,15 +479,84 @@ const copyToClipboard = async (text) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk import functions
|
||||
const parsedBulkLinks = computed(() => {
|
||||
if (!bulkUrls.value.trim()) return [];
|
||||
|
||||
return bulkUrls.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.map(line => {
|
||||
const parts = line.split(',');
|
||||
const url = parts[0].trim();
|
||||
const title = parts.length > 1 ? parts.slice(1).join(',').trim() : null;
|
||||
return { destinationUrl: url, title };
|
||||
})
|
||||
.filter(item => {
|
||||
try {
|
||||
new URL(item.destinationUrl);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const closeBulkModal = () => {
|
||||
showBulkModal.value = false;
|
||||
bulkUrls.value = '';
|
||||
bulkError.value = '';
|
||||
bulkResults.value = null;
|
||||
};
|
||||
|
||||
const importBulkLinks = async () => {
|
||||
if (parsedBulkLinks.value.length === 0) return;
|
||||
|
||||
bulkImporting.value = true;
|
||||
bulkError.value = '';
|
||||
|
||||
try {
|
||||
const workspaceId = workspaceStore.currentWorkspaceId;
|
||||
const result = await api.bulkCreateLinks(workspaceId, parsedBulkLinks.value);
|
||||
bulkResults.value = result;
|
||||
|
||||
// Refresh links list
|
||||
await workspaceStore.fetchLinks();
|
||||
} catch (err) {
|
||||
bulkError.value = err.message;
|
||||
} finally {
|
||||
bulkImporting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleted = async () => {
|
||||
showDeleted.value = true;
|
||||
await workspaceStore.fetchLinks({ includeDeleted: true });
|
||||
};
|
||||
|
||||
const restoreLink = async (link) => {
|
||||
try {
|
||||
await api.restoreLink(workspaceStore.currentWorkspaceId, link.id);
|
||||
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
|
||||
} catch (err) {
|
||||
console.error('Failed to restore link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await workspaceStore.fetchLinks();
|
||||
});
|
||||
|
||||
watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
if (workspaceStore.currentWorkspaceId) {
|
||||
await workspaceStore.fetchLinks();
|
||||
await workspaceStore.fetchLinks({ includeDeleted: showDeleted.value });
|
||||
}
|
||||
});
|
||||
|
||||
watch(showDeleted, async (value) => {
|
||||
await workspaceStore.fetchLinks({ includeDeleted: value });
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -581,6 +842,176 @@ watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* UTM Builder styles */
|
||||
.utm-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.utm-toggle {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.utm-toggle:hover {
|
||||
background: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.utm-toggle svg:last-child {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.utm-toggle svg.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.utm-fields {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.utm-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.utm-preset-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.utm-preset-btn {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.utm-preset-btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.utm-preview {
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.utm-preview strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Bulk import styles */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-lg {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.bulk-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.bulk-instructions {
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bulk-example {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.bulk-textarea {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bulk-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bulk-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bulk-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bulk-success {
|
||||
padding: 12px 16px;
|
||||
background: #dcfce7;
|
||||
border-radius: 10px;
|
||||
color: #16a34a;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bulk-errors {
|
||||
padding: 12px 16px;
|
||||
background: #fef2f2;
|
||||
border-radius: 10px;
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bulk-errors ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.bulk-errors li {
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.link-card {
|
||||
flex-direction: column;
|
||||
|
||||
566
src/frontend/src/views/projects/Projects.vue
Normal file
566
src/frontend/src/views/projects/Projects.vue
Normal file
@@ -0,0 +1,566 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="projects">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Projects</h1>
|
||||
<p class="subtitle">Organize your links and QR codes into projects</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
New Project
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">Loading projects...</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to organize your links and QR codes.</p>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">Create Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="projects-grid">
|
||||
<div v-for="project in projects" :key="project.id" class="project-card">
|
||||
<div class="project-header">
|
||||
<div class="project-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="icon-btn" @click="editProject(project)" title="Edit">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn danger" @click="confirmDelete(project)" title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="project-name">{{ project.name }}</h3>
|
||||
<p class="project-description">{{ project.description || 'No description' }}</p>
|
||||
<div class="project-meta">
|
||||
<span class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
{{ project.linkCount || 0 }} links
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<rect x="7" y="7" width="3" height="3"/>
|
||||
<rect x="14" y="7" width="3" height="3"/>
|
||||
<rect x="7" y="14" width="3" height="3"/>
|
||||
<rect x="14" y="14" width="3" height="3"/>
|
||||
</svg>
|
||||
{{ project.qrCodeCount || 0 }} QR codes
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
Created {{ formatDate(project.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div v-if="showCreateModal || showEditModal" class="modal-overlay" @click.self="closeModals">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ showEditModal ? 'Edit Project' : 'New Project' }}</h2>
|
||||
<button class="close-btn" @click="closeModals">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="showEditModal ? updateProject() : createProject()">
|
||||
<div class="form-group">
|
||||
<label for="name">Project Name</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="e.g., Marketing Campaign Q1"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Describe what this project is for..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModals">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (showEditModal ? 'Update' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2>Delete Project</h2>
|
||||
<button class="close-btn" @click="showDeleteModal = false">×</button>
|
||||
</div>
|
||||
<p class="delete-warning">
|
||||
Are you sure you want to delete <strong>{{ projectToDelete?.name }}</strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="btn btn-danger" @click="deleteProject" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
import AppLayout from '../../components/layout/AppLayout.vue';
|
||||
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const projects = computed(() => workspaceStore.projects);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
|
||||
const form = ref({ name: '', description: '' });
|
||||
const editingProject = ref(null);
|
||||
const projectToDelete = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects();
|
||||
});
|
||||
|
||||
watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
if (workspaceStore.currentWorkspaceId) {
|
||||
await loadProjects();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await workspaceStore.fetchProjects();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await workspaceStore.createProject(form.value.name, form.value.description);
|
||||
closeModals();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editProject(project) {
|
||||
editingProject.value = project;
|
||||
form.value = { name: project.name, description: project.description || '' };
|
||||
showEditModal.value = true;
|
||||
}
|
||||
|
||||
async function updateProject() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await workspaceStore.updateProject(editingProject.value.id, {
|
||||
name: form.value.name,
|
||||
description: form.value.description
|
||||
});
|
||||
closeModals();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(project) {
|
||||
projectToDelete.value = project;
|
||||
showDeleteModal.value = true;
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await workspaceStore.deleteProject(projectToDelete.value.id);
|
||||
showDeleteModal.value = false;
|
||||
projectToDelete.value = null;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModals() {
|
||||
showCreateModal.value = false;
|
||||
showEditModal.value = false;
|
||||
form.value = { name: '', description: '' };
|
||||
editingProject.value = null;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projects {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: #d1d5db;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.project-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.icon-btn.danger:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
padding: 1.5rem;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -142,6 +142,84 @@
|
||||
/>
|
||||
<span class="range-value">{{ formData.style.quietZone }} modules</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Module Shape</label>
|
||||
<div class="shape-selector">
|
||||
<button
|
||||
v-for="shape in moduleShapes"
|
||||
:key="shape.value"
|
||||
:class="{ active: formData.style.moduleShape === shape.value }"
|
||||
@click="formData.style.moduleShape = shape.value"
|
||||
class="shape-btn"
|
||||
>
|
||||
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
|
||||
{{ shape.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Eye Shape</label>
|
||||
<div class="shape-selector">
|
||||
<button
|
||||
v-for="shape in eyeShapes"
|
||||
:key="shape.value"
|
||||
:class="{ active: formData.style.eyeShape === shape.value }"
|
||||
@click="formData.style.eyeShape = shape.value"
|
||||
class="shape-btn"
|
||||
>
|
||||
<span class="shape-icon" :class="shape.value.toLowerCase()"></span>
|
||||
{{ shape.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">Eyes are the large corner patterns for scanner detection</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h2>Logo</h2>
|
||||
<div class="logo-section">
|
||||
<div class="current-logo" v-if="formData.logoAssetId">
|
||||
<img :src="getLogoUrl(formData.logoAssetId)" alt="Current logo" class="logo-preview" />
|
||||
<button @click="removeLogo" class="remove-logo-btn" title="Remove logo">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="logo-upload" v-else>
|
||||
<label class="upload-btn">
|
||||
<input
|
||||
type="file"
|
||||
@change="handleLogoUpload"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
hidden
|
||||
/>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Upload Logo
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="assets.length > 0 && !formData.logoAssetId" class="existing-logos">
|
||||
<p class="hint">Or select from existing:</p>
|
||||
<div class="logos-grid">
|
||||
<button
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
@click="selectLogo(asset)"
|
||||
class="logo-option"
|
||||
>
|
||||
<img :src="asset.url" :alt="asset.filename" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" v-if="formData.logoAssetId">Use high error correction (H) for better logo visibility</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
@@ -188,42 +266,96 @@ const saving = ref(false);
|
||||
const error = ref('');
|
||||
const previewUrl = ref('');
|
||||
const previewTimeout = ref(null);
|
||||
const uploadingLogo = ref(false);
|
||||
const assets = computed(() => workspaceStore.assets.filter(a => a.type === 'Logo'));
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
linkId: '',
|
||||
logoAssetId: null,
|
||||
style: {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
errorCorrectionLevel: 'M',
|
||||
quietZone: 4,
|
||||
moduleShape: 'Square',
|
||||
eyeShape: 'Square',
|
||||
},
|
||||
});
|
||||
|
||||
const moduleShapes = [
|
||||
{ value: 'Square', label: 'Square' },
|
||||
{ value: 'Rounded', label: 'Rounded' },
|
||||
{ value: 'Dots', label: 'Dots' },
|
||||
];
|
||||
|
||||
const eyeShapes = [
|
||||
{ value: 'Square', label: 'Square' },
|
||||
{ value: 'Rounded', label: 'Rounded' },
|
||||
{ value: 'Circle', label: 'Circle' },
|
||||
];
|
||||
|
||||
const getLogoUrl = (assetId) => {
|
||||
const asset = workspaceStore.assets.find(a => a.id === assetId);
|
||||
return asset?.url || '';
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
uploadingLogo.value = true;
|
||||
try {
|
||||
const asset = await workspaceStore.uploadAsset(file);
|
||||
formData.value.logoAssetId = asset.id;
|
||||
// If adding a logo, suggest high error correction
|
||||
if (formData.value.style.errorCorrectionLevel !== 'H') {
|
||||
formData.value.style.errorCorrectionLevel = 'H';
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to upload logo: ' + err.message;
|
||||
} finally {
|
||||
uploadingLogo.value = false;
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const selectLogo = (asset) => {
|
||||
formData.value.logoAssetId = asset.id;
|
||||
// If adding a logo, suggest high error correction
|
||||
if (formData.value.style.errorCorrectionLevel !== 'H') {
|
||||
formData.value.style.errorCorrectionLevel = 'H';
|
||||
}
|
||||
};
|
||||
|
||||
const removeLogo = () => {
|
||||
formData.value.logoAssetId = null;
|
||||
};
|
||||
|
||||
const presets = [
|
||||
{
|
||||
name: 'Classic',
|
||||
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4 }
|
||||
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
|
||||
},
|
||||
{
|
||||
name: 'Dark',
|
||||
style: { foregroundColor: '#ffffff', backgroundColor: '#1a1a1a', errorCorrectionLevel: 'M', quietZone: 4 }
|
||||
name: 'Modern',
|
||||
style: { foregroundColor: '#1a1a1a', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
|
||||
},
|
||||
{
|
||||
name: 'Dots',
|
||||
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'H', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
|
||||
},
|
||||
{
|
||||
name: 'Ocean',
|
||||
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4 }
|
||||
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Rounded', eyeShape: 'Rounded' }
|
||||
},
|
||||
{
|
||||
name: 'Forest',
|
||||
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4 }
|
||||
},
|
||||
{
|
||||
name: 'Sunset',
|
||||
style: { foregroundColor: '#c2410c', backgroundColor: '#fff7ed', errorCorrectionLevel: 'M', quietZone: 4 }
|
||||
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Square', eyeShape: 'Square' }
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4 }
|
||||
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4, moduleShape: 'Dots', eyeShape: 'Circle' }
|
||||
},
|
||||
];
|
||||
|
||||
@@ -258,11 +390,16 @@ const save = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name: formData.value.name,
|
||||
linkId: formData.value.linkId,
|
||||
shortLinkId: formData.value.linkId,
|
||||
logoAssetId: formData.value.logoAssetId,
|
||||
style: formData.value.style,
|
||||
};
|
||||
|
||||
if (isEditing.value) {
|
||||
// For updates, handle logo removal
|
||||
if (!formData.value.logoAssetId) {
|
||||
data.removeLogo = true;
|
||||
}
|
||||
await workspaceStore.updateQRCode(route.params.id, data);
|
||||
} else {
|
||||
await workspaceStore.createQRCode(data);
|
||||
@@ -291,15 +428,19 @@ const loadExisting = async () => {
|
||||
|
||||
try {
|
||||
const qr = await api.getQRCode(workspaceStore.currentWorkspaceId, route.params.id);
|
||||
const defaultStyle = {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
errorCorrectionLevel: 'M',
|
||||
quietZone: 4,
|
||||
moduleShape: 'Square',
|
||||
eyeShape: 'Square',
|
||||
};
|
||||
formData.value = {
|
||||
name: qr.name,
|
||||
linkId: qr.linkId,
|
||||
style: qr.style || {
|
||||
foregroundColor: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
errorCorrectionLevel: 'M',
|
||||
quietZone: 4,
|
||||
},
|
||||
linkId: qr.shortLinkId || qr.linkId,
|
||||
logoAssetId: qr.logoAssetId || null,
|
||||
style: { ...defaultStyle, ...qr.style },
|
||||
};
|
||||
await fetchPreview();
|
||||
} catch (err) {
|
||||
@@ -312,8 +453,16 @@ watch(() => formData.value.style, () => {
|
||||
previewTimeout.value = setTimeout(fetchPreview, 500);
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => formData.value.logoAssetId, () => {
|
||||
if (previewTimeout.value) clearTimeout(previewTimeout.value);
|
||||
previewTimeout.value = setTimeout(fetchPreview, 500);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await workspaceStore.fetchLinks();
|
||||
await Promise.all([
|
||||
workspaceStore.fetchLinks(),
|
||||
workspaceStore.fetchAssets(),
|
||||
]);
|
||||
if (isEditing.value) {
|
||||
await loadExisting();
|
||||
}
|
||||
@@ -519,6 +668,150 @@ onMounted(async () => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.current-logo {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.remove-logo-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.remove-logo-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.logo-upload {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg);
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.existing-logos {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.logos-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.logo-option {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 4px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.logo-option:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.logo-option img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.shape-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shape-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 8px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.shape-btn:hover {
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.shape-btn.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 106, 61, 0.1);
|
||||
}
|
||||
|
||||
.shape-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.shape-icon.square {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.shape-icon.rounded {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.shape-icon.dots,
|
||||
.shape-icon.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.designer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
514
src/frontend/src/views/qrcodes/QRCodeDetail.vue
Normal file
514
src/frontend/src/views/qrcodes/QRCodeDetail.vue
Normal file
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="qrcode-detail">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<router-link to="/qrcodes" class="back-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to QR Codes
|
||||
</router-link>
|
||||
<h1>{{ qrCode?.name || 'QR Code' }}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/qrcodes/${id}`" class="btn btn-secondary">
|
||||
Edit Design
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">Loading analytics...</div>
|
||||
|
||||
<template v-else-if="analytics">
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon scans">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="6" height="6"/>
|
||||
<rect x="15" y="3" width="6" height="6"/>
|
||||
<rect x="3" y="15" width="6" height="6"/>
|
||||
<rect x="15" y="15" width="6" height="6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-value">{{ analytics.summary.totalScans }}</p>
|
||||
<p class="stat-label">Total Scans</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon visitors">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-value">{{ analytics.summary.uniqueVisitors }}</p>
|
||||
<p class="stat-label">Unique Visitors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Selector -->
|
||||
<div class="period-selector">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="{ active: period === p.value }"
|
||||
@click="setPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<section class="card chart-card">
|
||||
<h2>Scans Over Time</h2>
|
||||
<div class="chart-container" v-if="analytics.timeSeries?.length">
|
||||
<div class="chart">
|
||||
<div
|
||||
v-for="(point, i) in analytics.timeSeries"
|
||||
:key="i"
|
||||
class="chart-bar"
|
||||
:style="{ height: getBarHeight(point.scans) + '%' }"
|
||||
:title="`${point.date}: ${point.scans} scans`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No scan data for this period</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Breakdowns -->
|
||||
<div class="breakdown-grid">
|
||||
<!-- Device Breakdown -->
|
||||
<section class="card">
|
||||
<h2>Devices</h2>
|
||||
<div v-if="Object.keys(analytics.deviceBreakdown).length" class="breakdown-list">
|
||||
<div
|
||||
v-for="(count, device) in analytics.deviceBreakdown"
|
||||
:key="device"
|
||||
class="breakdown-item"
|
||||
>
|
||||
<span class="breakdown-label">{{ device }}</span>
|
||||
<div class="breakdown-bar-container">
|
||||
<div
|
||||
class="breakdown-bar"
|
||||
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="breakdown-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No device data</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Country Breakdown -->
|
||||
<section class="card">
|
||||
<h2>Countries</h2>
|
||||
<div v-if="Object.keys(analytics.countryBreakdown).length" class="breakdown-list">
|
||||
<div
|
||||
v-for="(count, country) in analytics.countryBreakdown"
|
||||
:key="country"
|
||||
class="breakdown-item"
|
||||
>
|
||||
<span class="breakdown-label">
|
||||
{{ getCountryFlag(country) }} {{ getCountryName(country) }}
|
||||
</span>
|
||||
<div class="breakdown-bar-container">
|
||||
<div
|
||||
class="breakdown-bar"
|
||||
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="breakdown-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No country data</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Referrer Breakdown -->
|
||||
<section class="card">
|
||||
<h2>Referrers</h2>
|
||||
<div v-if="Object.keys(analytics.referrerBreakdown).length" class="breakdown-list">
|
||||
<div
|
||||
v-for="(count, referrer) in analytics.referrerBreakdown"
|
||||
:key="referrer"
|
||||
class="breakdown-item"
|
||||
>
|
||||
<span class="breakdown-label">{{ referrer }}</span>
|
||||
<div class="breakdown-bar-container">
|
||||
<div
|
||||
class="breakdown-bar"
|
||||
:style="{ width: getBreakdownPercent(count, analytics.summary.totalScans) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="breakdown-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No referrer data</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api } from '../../api/client';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
import AppLayout from '../../components/layout/AppLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const id = computed(() => route.params.id);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const qrCode = ref(null);
|
||||
const analytics = ref(null);
|
||||
const period = ref('7d');
|
||||
|
||||
const periods = [
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
{ value: '30d', label: '30d' },
|
||||
{ value: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
const countryNames = {
|
||||
US: 'United States', GB: 'United Kingdom', CA: 'Canada', AU: 'Australia',
|
||||
DE: 'Germany', FR: 'France', JP: 'Japan', CN: 'China', IN: 'India',
|
||||
BR: 'Brazil', MX: 'Mexico', ES: 'Spain', IT: 'Italy', NL: 'Netherlands'
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const workspaceId = workspaceStore.currentWorkspace?.id;
|
||||
if (!workspaceId) return;
|
||||
|
||||
const [qrData, analyticsData] = await Promise.all([
|
||||
api.getQRCode(workspaceId, id.value),
|
||||
api.getQRCodeAnalytics(workspaceId, id.value, period.value)
|
||||
]);
|
||||
|
||||
qrCode.value = qrData;
|
||||
analytics.value = analyticsData;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setPeriod(newPeriod) {
|
||||
period.value = newPeriod;
|
||||
loading.value = true;
|
||||
try {
|
||||
const workspaceId = workspaceStore.currentWorkspace?.id;
|
||||
analytics.value = await api.getQRCodeAnalytics(workspaceId, id.value, newPeriod);
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBarHeight(value) {
|
||||
if (!analytics.value?.timeSeries?.length) return 0;
|
||||
const max = Math.max(...analytics.value.timeSeries.map(p => p.scans));
|
||||
return max > 0 ? (value / max) * 100 : 0;
|
||||
}
|
||||
|
||||
function getBreakdownPercent(count, total) {
|
||||
return total > 0 ? (count / total) * 100 : 0;
|
||||
}
|
||||
|
||||
function getCountryFlag(code) {
|
||||
const codePoints = code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
}
|
||||
|
||||
function getCountryName(code) {
|
||||
return countryNames[code] || code;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qrcode-detail {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon.scans {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.stat-icon.visitors {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.period-selector button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.period-selector button:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.period-selector button.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 4px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.breakdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.breakdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
width: 120px;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.breakdown-bar-container {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.breakdown-bar {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.breakdown-count {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #dc2626;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.breakdown-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
991
src/frontend/src/views/settings/Settings.vue
Normal file
991
src/frontend/src/views/settings/Settings.vue
Normal file
@@ -0,0 +1,991 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="settings">
|
||||
<header class="page-header">
|
||||
<h1>Account Settings</h1>
|
||||
<p class="subtitle">Manage your account and preferences</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Profile Section -->
|
||||
<section class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2>Profile</h2>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<form @submit.prevent="updateProfile">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="profile.email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
<p v-if="!profile.isVerified" class="form-warning">
|
||||
Your email is not verified.
|
||||
<button type="button" class="link-btn" @click="resendVerification" :disabled="resendingVerification">
|
||||
{{ resendingVerification ? 'Sending...' : 'Resend verification email' }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="profileError" class="error-message">{{ profileError }}</div>
|
||||
<div v-if="profileSuccess" class="success-message">{{ profileSuccess }}</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="savingProfile">
|
||||
{{ savingProfile ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Password Section -->
|
||||
<section class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<form @submit.prevent="changePassword">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">Current Password</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newPassword">New Password</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<p class="form-hint">At least 8 characters</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm New Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||
<div v-if="passwordSuccess" class="success-message">{{ passwordSuccess }}</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
|
||||
{{ changingPassword ? 'Changing...' : 'Change Password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<section class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2>API Keys</h2>
|
||||
<button class="btn btn-primary btn-sm" @click="showCreateKeyModal = true">
|
||||
Create Key
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<p class="section-description">Generate API keys to access TrakQR programmatically. Keys are scoped to your current workspace.</p>
|
||||
|
||||
<div v-if="loadingKeys" class="loading-inline">Loading API keys...</div>
|
||||
|
||||
<div v-else-if="apiKeys.length === 0" class="empty-state-inline">
|
||||
<p>No API keys yet. Create one to get started.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="api-keys-list">
|
||||
<div v-for="key in apiKeys" :key="key.id" class="api-key-item">
|
||||
<div class="api-key-info">
|
||||
<span class="api-key-name">{{ key.name }}</span>
|
||||
<code class="api-key-prefix">{{ key.keyPrefix }}</code>
|
||||
</div>
|
||||
<div class="api-key-meta">
|
||||
<span v-if="key.lastUsedAt" class="api-key-used">
|
||||
Last used {{ formatDate(key.lastUsedAt) }}
|
||||
</span>
|
||||
<span v-else class="api-key-used">Never used</span>
|
||||
<span v-if="key.expiresAt" :class="['api-key-expiry', { expired: isExpired(key.expiresAt) }]">
|
||||
{{ isExpired(key.expiresAt) ? 'Expired' : `Expires ${formatDate(key.expiresAt)}` }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-icon btn-danger-icon" @click="confirmDeleteKey(key)" title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<section class="settings-section danger-zone">
|
||||
<div class="section-header">
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="danger-item">
|
||||
<div>
|
||||
<h4>Delete Account</h4>
|
||||
<p>Permanently delete your account and all associated data. This action cannot be undone.</p>
|
||||
</div>
|
||||
<button class="btn btn-danger" @click="showDeleteModal = true">
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Create API Key Modal -->
|
||||
<div v-if="showCreateKeyModal" class="modal-overlay" @click.self="showCreateKeyModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title-normal">Create API Key</h2>
|
||||
<button class="close-btn" @click="showCreateKeyModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="createApiKey">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="keyName">Key Name</label>
|
||||
<input
|
||||
id="keyName"
|
||||
v-model="newKeyName"
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g., Production Server"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="keyExpiry">Expiration (optional)</label>
|
||||
<select id="keyExpiry" v-model="newKeyExpiry">
|
||||
<option value="">Never expires</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="createKeyError" class="error-message">{{ createKeyError }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateKeyModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="creatingKey">
|
||||
{{ creatingKey ? 'Creating...' : 'Create Key' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show New Key Modal -->
|
||||
<div v-if="newlyCreatedKey" class="modal-overlay">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title-normal">API Key Created</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="key-warning">
|
||||
Copy your API key now. You won't be able to see it again!
|
||||
</p>
|
||||
<div class="key-display">
|
||||
<code>{{ newlyCreatedKey }}</code>
|
||||
<button type="button" class="btn btn-icon" @click="copyKey" :title="keyCopied ? 'Copied!' : 'Copy'">
|
||||
<svg v-if="!keyCopied" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" @click="newlyCreatedKey = null">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete API Key Modal -->
|
||||
<div v-if="keyToDelete" class="modal-overlay" @click.self="keyToDelete = null">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2>Delete API Key?</h2>
|
||||
<button class="close-btn" @click="keyToDelete = null">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the API key "{{ keyToDelete.name }}"? Any applications using this key will stop working.</p>
|
||||
<div v-if="deleteKeyError" class="error-message">{{ deleteKeyError }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="keyToDelete = null">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" @click="deleteApiKey" :disabled="deletingKey">
|
||||
{{ deletingKey ? 'Deleting...' : 'Delete Key' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account Modal -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
||||
<div class="modal modal-sm">
|
||||
<div class="modal-header">
|
||||
<h2>Delete Account</h2>
|
||||
<button class="close-btn" @click="showDeleteModal = false">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="deleteAccount">
|
||||
<div class="modal-body">
|
||||
<p class="delete-warning">
|
||||
This will permanently delete your account, all workspaces, links, QR codes, and analytics data.
|
||||
This action <strong>cannot be undone</strong>.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="deletePassword">Enter your password to confirm</label>
|
||||
<input
|
||||
id="deletePassword"
|
||||
v-model="deletePassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Your password"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="deleteError" class="error-message">{{ deleteError }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger" :disabled="deleting">
|
||||
{{ deleting ? 'Deleting...' : 'Delete My Account' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useWorkspaceStore } from '../../stores/workspace';
|
||||
import AppLayout from '../../components/layout/AppLayout.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const profile = ref({ email: '', isVerified: false });
|
||||
|
||||
const savingProfile = ref(false);
|
||||
const profileError = ref('');
|
||||
const profileSuccess = ref('');
|
||||
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const changingPassword = ref(false);
|
||||
const passwordError = ref('');
|
||||
const passwordSuccess = ref('');
|
||||
|
||||
const showDeleteModal = ref(false);
|
||||
const deletePassword = ref('');
|
||||
const deleting = ref(false);
|
||||
const deleteError = ref('');
|
||||
|
||||
// API Keys state
|
||||
const apiKeys = ref([]);
|
||||
const loadingKeys = ref(false);
|
||||
const showCreateKeyModal = ref(false);
|
||||
const newKeyName = ref('');
|
||||
const newKeyExpiry = ref('');
|
||||
const creatingKey = ref(false);
|
||||
const createKeyError = ref('');
|
||||
const newlyCreatedKey = ref(null);
|
||||
const keyCopied = ref(false);
|
||||
const keyToDelete = ref(null);
|
||||
const deletingKey = ref(false);
|
||||
const deleteKeyError = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
profile.value = data;
|
||||
await loadApiKeys();
|
||||
} catch (err) {
|
||||
profileError.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => workspaceStore.currentWorkspaceId, async () => {
|
||||
if (workspaceStore.currentWorkspaceId) {
|
||||
await loadApiKeys();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadApiKeys() {
|
||||
const workspaceId = workspaceStore.currentWorkspaceId;
|
||||
if (!workspaceId) return;
|
||||
|
||||
loadingKeys.value = true;
|
||||
try {
|
||||
const result = await api.listApiKeys(workspaceId);
|
||||
apiKeys.value = result.apiKeys;
|
||||
} catch (err) {
|
||||
console.error('Failed to load API keys:', err);
|
||||
} finally {
|
||||
loadingKeys.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
savingProfile.value = true;
|
||||
profileError.value = '';
|
||||
profileSuccess.value = '';
|
||||
|
||||
try {
|
||||
const data = await api.updateProfile({ email: profile.value.email });
|
||||
profile.value = data;
|
||||
profileSuccess.value = 'Profile updated successfully';
|
||||
} catch (err) {
|
||||
profileError.value = err.message;
|
||||
} finally {
|
||||
savingProfile.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||
passwordError.value = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
changingPassword.value = true;
|
||||
passwordError.value = '';
|
||||
passwordSuccess.value = '';
|
||||
|
||||
try {
|
||||
await api.changePassword(
|
||||
passwordForm.value.currentPassword,
|
||||
passwordForm.value.newPassword
|
||||
);
|
||||
passwordSuccess.value = 'Password changed successfully';
|
||||
passwordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '' };
|
||||
} catch (err) {
|
||||
passwordError.value = err.message;
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const resendingVerification = ref(false);
|
||||
|
||||
async function resendVerification() {
|
||||
resendingVerification.value = true;
|
||||
profileError.value = '';
|
||||
try {
|
||||
await api.resendVerification();
|
||||
profileSuccess.value = 'Verification email sent! Check your inbox.';
|
||||
} catch (err) {
|
||||
profileError.value = err.message;
|
||||
} finally {
|
||||
resendingVerification.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
deleting.value = true;
|
||||
deleteError.value = '';
|
||||
|
||||
try {
|
||||
await api.deleteAccount(deletePassword.value);
|
||||
authStore.logout();
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
deleteError.value = err.message;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// API Key functions
|
||||
async function createApiKey() {
|
||||
const workspaceId = workspaceStore.currentWorkspaceId;
|
||||
if (!workspaceId) return;
|
||||
|
||||
creatingKey.value = true;
|
||||
createKeyError.value = '';
|
||||
|
||||
try {
|
||||
let expiresAt = null;
|
||||
if (newKeyExpiry.value) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + parseInt(newKeyExpiry.value));
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
|
||||
const result = await api.createApiKey(workspaceId, newKeyName.value, expiresAt);
|
||||
newlyCreatedKey.value = result.key;
|
||||
apiKeys.value.unshift({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
keyPrefix: result.keyPrefix,
|
||||
scopes: result.scopes,
|
||||
expiresAt: result.expiresAt,
|
||||
createdAt: result.createdAt,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
showCreateKeyModal.value = false;
|
||||
newKeyName.value = '';
|
||||
newKeyExpiry.value = '';
|
||||
} catch (err) {
|
||||
createKeyError.value = err.message;
|
||||
} finally {
|
||||
creatingKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newlyCreatedKey.value);
|
||||
keyCopied.value = true;
|
||||
setTimeout(() => {
|
||||
keyCopied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteKey(key) {
|
||||
keyToDelete.value = key;
|
||||
deleteKeyError.value = '';
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
const workspaceId = workspaceStore.currentWorkspaceId;
|
||||
if (!workspaceId || !keyToDelete.value) return;
|
||||
|
||||
deletingKey.value = true;
|
||||
deleteKeyError.value = '';
|
||||
|
||||
try {
|
||||
await api.deleteApiKey(workspaceId, keyToDelete.value.id);
|
||||
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value.id);
|
||||
keyToDelete.value = null;
|
||||
} catch (err) {
|
||||
deleteKeyError.value = err.message;
|
||||
} finally {
|
||||
deletingKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function isExpired(date) {
|
||||
if (!date) return false;
|
||||
return new Date(date) < new Date();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.section-content.muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.section-content.muted p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group:last-of-type {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.form-warning {
|
||||
font-size: 0.875rem;
|
||||
color: #d97706;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* Danger Zone */
|
||||
.danger-zone .section-header {
|
||||
background: #fef2f2;
|
||||
border-bottom-color: #fecaca;
|
||||
}
|
||||
|
||||
.danger-zone .section-header h2 {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.danger-item h4 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.danger-item p {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.error-message {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #065f46;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.delete-warning {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #991b1b;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* API Keys */
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.loading-inline {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.empty-state-inline {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-inline p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.api-keys-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.api-key-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.api-key-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.api-key-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.api-key-prefix {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.api-key-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.api-key-expiry.expired {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-danger-icon:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.modal-title-normal {
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
.key-warning {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
color: #92400e;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.key-display code {
|
||||
flex: 1;
|
||||
color: #10b981;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.key-display .btn-icon {
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key-display .btn-icon:hover {
|
||||
color: white;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.danger-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.api-key-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.api-key-meta {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user