feat(auth): adds basic endpoints: register, login, forgot password, reset password

This commit is contained in:
2026-01-27 16:02:00 -05:00
parent bc1ce0bbaa
commit 11d6390884
35 changed files with 2103 additions and 59 deletions

107
docs/architecture.md Normal file
View File

@@ -0,0 +1,107 @@
# TrakQR Architecture Guidelines
## Core Principles
### 1. Modular Monolith
The application is structured as a modular monolith where each feature/domain is self-contained but runs in a single deployable unit.
This provides:
- Clear boundaries between features
- Easy refactoring to microservices if needed later
- Simplified deployment and operations
### 2. Vertical Slice Architecture
Code is organized by **feature** (vertical slices), not by technical layer (horizontal).
Each feature contains everything it needs:
- Endpoint definitions
- Request/Response models
- Business logic
- Validators
```
/Features
/Auth
/Endpoints
SmallEndpoint.cs (contains Request, Response, Validator and Endpoint)
/Register
Endpoint.cs
Request.cs
Response.cs
Validator.cs
/Login
...
/Links
/Create
...
/List
...
/QRCodes
...
```
### 3. Minimal API with FastEndpoints
We use [FastEndpoints](https://fast-endpoints.com/) instead of traditional MVC Controllers because:
- Better performance (no reflection-based model binding)
- Cleaner, more focused endpoint classes
- Built-in validation with FluentValidation
- Request/Response DTOs are co-located with endpoints
- Easier testing
### 4. No Traditional Controllers
**DO NOT** use `[ApiController]` or `ControllerBase`. All HTTP endpoints must be FastEndpoints.
## Module Structure
Each feature module should be **fully self-contained**. All code related to a feature lives within that feature's folder:
```
/Features/{Module}/
- {Module}Responses.cs # Shared response DTOs for this module
- {Module}Settings.cs # Configuration classes for this module
/{Operation}/
- Endpoint.cs # The FastEndpoint class
- Request.cs # Input DTO
- Validator.cs # FluentValidation rules
```
**Example - Auth module:**
```
/Features/Auth/
- AuthResponses.cs # AuthResponse, UserInfo, MessageResponse
- JwtSettings.cs # JWT configuration
/Register/
- Endpoint.cs
- Request.cs
- Validator.cs
/Login/
- Endpoint.cs
- Request.cs
- Validator.cs
```
For simple operations, business logic lives directly in the Endpoint class. For complex logic, add a `Handler.cs` or `Service.cs` within the same feature folder.
## Shared Infrastructure
Only truly cross-cutting infrastructure goes outside Features:
- `/Data` - DbContext, migrations (shared database access)
- `/Models` - EF Core entities (shared domain model)
## Dependency Injection
- Use constructor injection
- Register services in `Program.cs` or feature-specific extension methods
- Prefer scoped services for request-specific work
## Validation
- Use FluentValidation via FastEndpoints' built-in support
- Validate at the endpoint level, not in services
- Return 400 Bad Request with structured error responses
## Authentication
- JWT Bearer tokens for API authentication
- Claims-based authorization
- User ID extracted from JWT `sub` claim

13
src/.idea/.idea.src/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/.idea.src.iml
/contentModel.xml
/projectSettingsUpdater.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

4
src/.idea/.idea.src/.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
src/.idea/.idea.src/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

482
src/api.Tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@@ -0,0 +1,50 @@
using api.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
namespace Api.Tests;
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// Run migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
}

View File

@@ -0,0 +1,225 @@
using System.Net;
using System.Net.Http.Json;
using api.Features.Auth.Common;
using FluentAssertions;
namespace Api.Tests;
public class AuthControllerTests(ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Register_WithValidCredentials_ReturnsTokenAndUser()
{
// Arrange
var request = new { Email = "newuser@example.com", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result.Should().NotBeNull();
result!.Token.Should().NotBeNullOrEmpty();
result.User.Email.Should().Be("newuser@example.com");
result.User.IsVerified.Should().BeFalse();
result.ExpiresAt.Should().BeAfter(DateTime.UtcNow);
}
[Fact]
public async Task Register_WithDuplicateEmail_ReturnsConflict()
{
// Arrange
var request = new { Email = "duplicate@example.com", Password = "password123" };
// First registration
await _client.PostAsJsonAsync("/auth/register", request);
// Act - try to register again
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Email already registered");
}
[Fact]
public async Task Register_WithInvalidEmail_ReturnsBadRequest()
{
// Arrange
var request = new { Email = "not-an-email", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Register_WithShortPassword_ReturnsBadRequest()
{
// Arrange
var request = new { Email = "shortpw@example.com", Password = "short" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Login_WithValidCredentials_ReturnsToken()
{
// Arrange
var email = "logintest@example.com";
var password = "password123";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = password });
// Act
var response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = password });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result.Should().NotBeNull();
result!.Token.Should().NotBeNullOrEmpty();
result.User.Email.Should().Be(email);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsUnauthorized()
{
// Arrange
var email = "wrongpw@example.com";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "correctpassword" });
// Act
var response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "wrongpassword" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Invalid email or password");
}
[Fact]
public async Task Login_WithNonExistentEmail_ReturnsUnauthorized()
{
// Arrange
var request = new { Email = "nonexistent@example.com", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/login", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Invalid email or password");
}
[Fact]
public async Task ForgotPassword_WithAnyEmail_ReturnsSuccessMessage()
{
// Arrange - using a non-existent email to verify we don't leak info
var request = new { Email = "anyone@example.com" };
// Act
var response = await _client.PostAsJsonAsync("/auth/forgot", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("If the email exists, a reset link will be sent");
}
[Fact]
public async Task ForgotPassword_WithExistingEmail_ReturnsSuccessMessage()
{
// Arrange
var email = "forgotpw@example.com";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
// Act
var response = await _client.PostAsJsonAsync("/auth/forgot", new { Email = email });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("If the email exists, a reset link will be sent");
}
[Fact]
public async Task ResetPassword_ReturnsNotImplemented()
{
// Arrange
var request = new { Token = "some-token", NewPassword = "newpassword123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/reset", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Password reset is not yet available");
}
[Fact]
public async Task Register_CreatesDefaultWorkspace()
{
// Arrange
var request = new { Email = "workspace@example.com", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result.Should().NotBeNull();
result!.User.Id.Should().NotBeEmpty();
// The workspace is created but not returned in auth response
// This could be verified with a separate workspaces endpoint test
}
[Fact]
public async Task Login_IsCaseInsensitive()
{
// Arrange
var email = "CaseTEST@Example.COM";
var password = "password123";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = password });
// Act - login with different casing
var response = await _client.PostAsJsonAsync("/auth/login",
new { Email = "casetest@example.com", Password = password });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result!.User.Email.Should().Be("casetest@example.com");
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\api\api.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,20 +1,17 @@
using Api.Models;
using api.Models;
using Microsoft.EntityFrameworkCore;
namespace Api.Data;
namespace api.Data;
public class AppDbContext : DbContext
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<User> Users => Set<User>();
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<Domain> Domains => Set<Domain>();
public DbSet<ShortLink> ShortLinks => Set<ShortLink>();
public DbSet<QRCodeDesign> QRCodeDesigns => Set<QRCodeDesign>();
public DbSet<QRCodeDesign> QrCodeDesigns => Set<QRCodeDesign>();
public DbSet<Event> Events => Set<Event>();
public DbSet<Asset> Assets => Set<Asset>();

View File

@@ -0,0 +1,15 @@
namespace api.Features.Auth.Common;
public record AuthResponse(
string Token,
DateTime ExpiresAt,
UserInfo User
);
public record UserInfo(
Guid Id,
string Email,
bool IsVerified
);
public record MessageResponse(string Message);

View File

@@ -0,0 +1,55 @@
using System.Security.Cryptography;
using api.Data;
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace api.Features.Auth.Endpoints;
public class ForgotPasswordRequest
{
public string Email { get; set; } = string.Empty;
}
public class ForgotPasswordValidator : Validator<ForgotPasswordRequest>
{
public ForgotPasswordValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
}
}
public class ForgotPasswordEndpoint(AppDbContext db)
: Endpoint<ForgotPasswordRequest, MessageResponse>
{
public override void Configure()
{
Post("/auth/forgot");
AllowAnonymous();
}
public override async Task HandleAsync(ForgotPasswordRequest req, CancellationToken ct)
{
var normalizedEmail = req.Email.ToLowerInvariant();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct);
if (user == null)
{
Logger.LogInformation("Password reset requested for non-existent email: {Email}", normalizedEmail);
}
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);
}
// 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);
}
}

View File

@@ -0,0 +1,85 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace api.Features.Auth.Endpoints;
public class LoginRequest
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class LoginValidator : Validator<LoginRequest>
{
public LoginValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required");
}
}
public class LoginEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
: Endpoint<LoginRequest, AuthResponse>
{
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
public override void Configure()
{
Post("/auth/login");
AllowAnonymous();
}
public override async Task HandleAsync(LoginRequest req, CancellationToken ct)
{
var normalizedEmail = req.Email.ToLowerInvariant();
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == normalizedEmail, ct);
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
{
await HttpContext.Response.SendAsync(new MessageResponse("Invalid email or password"), 401, cancellation: ct);
return;
}
Logger.LogInformation("User logged in: {Email}", normalizedEmail);
var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: expiresAt,
signingCredentials: credentials
);
var response = new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
);
await HttpContext.Response.SendAsync(response, 200, cancellation: ct);
}
}

View File

@@ -0,0 +1,114 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using api.Data;
using api.Features.Auth.Common;
using api.Features.Auth.Settings;
using api.Models;
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace api.Features.Auth.Endpoints;
public class RegisterRequest
{
public string Email { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
public class RegisterValidator : Validator<RegisterRequest>
{
public RegisterValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MaximumLength(255).WithMessage("Email must not exceed 255 characters");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.MaximumLength(100).WithMessage("Password must not exceed 100 characters");
}
}
public class RegisterEndpoint(AppDbContext db, IOptions<JwtSettings> jwtSettings)
: Endpoint<RegisterRequest, AuthResponse>
{
private readonly JwtSettings _jwtSettings = jwtSettings.Value;
public override void Configure()
{
Post("/auth/register");
AllowAnonymous();
}
public override async Task HandleAsync(RegisterRequest req, CancellationToken ct)
{
var normalizedEmail = req.Email.ToLowerInvariant();
if (await db.Users.AnyAsync(u => u.Email == normalizedEmail, ct))
{
await HttpContext.Response.SendAsync(new MessageResponse("Email already registered"), 409, cancellation: ct);
return;
}
var user = new User
{
Id = Guid.NewGuid(),
Email = normalizedEmail,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
CreatedAt = DateTime.UtcNow
};
db.Users.Add(user);
var workspace = new Workspace
{
Id = Guid.NewGuid(),
OwnerUserId = user.Id,
Name = "My Workspace",
Plan = WorkspacePlan.Free,
CreatedAt = DateTime.UtcNow
};
db.Workspaces.Add(workspace);
await db.SaveChangesAsync(ct);
Logger.LogInformation("User registered: {Email}", normalizedEmail);
var response = GenerateAuthResponse(user);
await HttpContext.Response.SendAsync(response, 201, cancellation: ct);
}
private AuthResponse GenerateAuthResponse(User user)
{
var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: expiresAt,
signingCredentials: credentials
);
return new AuthResponse(
Token: new JwtSecurityTokenHandler().WriteToken(token),
ExpiresAt: expiresAt,
User: new UserInfo(user.Id, user.Email, user.VerifiedAt.HasValue)
);
}
}

View File

@@ -0,0 +1,46 @@
using api.Features.Auth.Common;
using FastEndpoints;
using FluentValidation;
namespace api.Features.Auth.Endpoints;
public class ResetPasswordRequest
{
public string Token { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
}
public class ValidatorResetPassword : Validator<ResetPasswordRequest>
{
public ValidatorResetPassword()
{
RuleFor(x => x.Token)
.NotEmpty().WithMessage("Token is required");
RuleFor(x => x.NewPassword)
.NotEmpty().WithMessage("New password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.MaximumLength(100).WithMessage("Password must not exceed 100 characters");
}
}
public class ResetPasswordEndpoint : Endpoint<ResetPasswordRequest, MessageResponse>
{
public override void Configure()
{
Post("/auth/reset");
AllowAnonymous();
}
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
await HttpContext.Response.SendAsync(new MessageResponse("Password reset is not yet available"), 400, cancellation: ct);
}
}

View File

@@ -0,0 +1,9 @@
namespace api.Features.Auth.Settings;
public class JwtSettings
{
public required string Secret { get; set; }
public required string Issuer { get; set; }
public required string Audience { get; set; }
public int ExpirationMinutes { get; set; } = 60;
}

View File

@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using Api.Data;
using api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using Api.Data;
using api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -0,0 +1,540 @@
// <auto-generated />
using System;
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("20260127205418_refactor auth")]
partial class RefactorAuth
{
/// <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.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.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.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<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<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<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.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Workspaces");
});
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.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.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
}
}
}

View File

@@ -0,0 +1,204 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace api.Migrations
{
/// <inheritdoc />
public partial class RefactorAuth : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Events_QRCodeDesigns_QRCodeId",
table: "Events");
migrationBuilder.DropForeignKey(
name: "FK_QRCodeDesigns_Assets_LogoAssetId",
table: "QRCodeDesigns");
migrationBuilder.DropForeignKey(
name: "FK_QRCodeDesigns_Projects_ProjectId",
table: "QRCodeDesigns");
migrationBuilder.DropForeignKey(
name: "FK_QRCodeDesigns_ShortLinks_ShortLinkId",
table: "QRCodeDesigns");
migrationBuilder.DropForeignKey(
name: "FK_QRCodeDesigns_Workspaces_WorkspaceId",
table: "QRCodeDesigns");
migrationBuilder.DropPrimaryKey(
name: "PK_QRCodeDesigns",
table: "QRCodeDesigns");
migrationBuilder.RenameTable(
name: "QRCodeDesigns",
newName: "QrCodeDesigns");
migrationBuilder.RenameIndex(
name: "IX_QRCodeDesigns_WorkspaceId",
table: "QrCodeDesigns",
newName: "IX_QrCodeDesigns_WorkspaceId");
migrationBuilder.RenameIndex(
name: "IX_QRCodeDesigns_ShortLinkId",
table: "QrCodeDesigns",
newName: "IX_QrCodeDesigns_ShortLinkId");
migrationBuilder.RenameIndex(
name: "IX_QRCodeDesigns_ProjectId",
table: "QrCodeDesigns",
newName: "IX_QrCodeDesigns_ProjectId");
migrationBuilder.RenameIndex(
name: "IX_QRCodeDesigns_LogoAssetId",
table: "QrCodeDesigns",
newName: "IX_QrCodeDesigns_LogoAssetId");
migrationBuilder.AddPrimaryKey(
name: "PK_QrCodeDesigns",
table: "QrCodeDesigns",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Events_QrCodeDesigns_QRCodeId",
table: "Events",
column: "QRCodeId",
principalTable: "QrCodeDesigns",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QrCodeDesigns_Assets_LogoAssetId",
table: "QrCodeDesigns",
column: "LogoAssetId",
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QrCodeDesigns_Projects_ProjectId",
table: "QrCodeDesigns",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QrCodeDesigns_ShortLinks_ShortLinkId",
table: "QrCodeDesigns",
column: "ShortLinkId",
principalTable: "ShortLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QrCodeDesigns_Workspaces_WorkspaceId",
table: "QrCodeDesigns",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Events_QrCodeDesigns_QRCodeId",
table: "Events");
migrationBuilder.DropForeignKey(
name: "FK_QrCodeDesigns_Assets_LogoAssetId",
table: "QrCodeDesigns");
migrationBuilder.DropForeignKey(
name: "FK_QrCodeDesigns_Projects_ProjectId",
table: "QrCodeDesigns");
migrationBuilder.DropForeignKey(
name: "FK_QrCodeDesigns_ShortLinks_ShortLinkId",
table: "QrCodeDesigns");
migrationBuilder.DropForeignKey(
name: "FK_QrCodeDesigns_Workspaces_WorkspaceId",
table: "QrCodeDesigns");
migrationBuilder.DropPrimaryKey(
name: "PK_QrCodeDesigns",
table: "QrCodeDesigns");
migrationBuilder.RenameTable(
name: "QrCodeDesigns",
newName: "QRCodeDesigns");
migrationBuilder.RenameIndex(
name: "IX_QrCodeDesigns_WorkspaceId",
table: "QRCodeDesigns",
newName: "IX_QRCodeDesigns_WorkspaceId");
migrationBuilder.RenameIndex(
name: "IX_QrCodeDesigns_ShortLinkId",
table: "QRCodeDesigns",
newName: "IX_QRCodeDesigns_ShortLinkId");
migrationBuilder.RenameIndex(
name: "IX_QrCodeDesigns_ProjectId",
table: "QRCodeDesigns",
newName: "IX_QRCodeDesigns_ProjectId");
migrationBuilder.RenameIndex(
name: "IX_QrCodeDesigns_LogoAssetId",
table: "QRCodeDesigns",
newName: "IX_QRCodeDesigns_LogoAssetId");
migrationBuilder.AddPrimaryKey(
name: "PK_QRCodeDesigns",
table: "QRCodeDesigns",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Events_QRCodeDesigns_QRCodeId",
table: "Events",
column: "QRCodeId",
principalTable: "QRCodeDesigns",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QRCodeDesigns_Assets_LogoAssetId",
table: "QRCodeDesigns",
column: "LogoAssetId",
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QRCodeDesigns_Projects_ProjectId",
table: "QRCodeDesigns",
column: "ProjectId",
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QRCodeDesigns_ShortLinks_ShortLinkId",
table: "QRCodeDesigns",
column: "ShortLinkId",
principalTable: "ShortLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_QRCodeDesigns_Workspaces_WorkspaceId",
table: "QRCodeDesigns",
column: "WorkspaceId",
principalTable: "Workspaces",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -1,10 +1,10 @@
// <auto-generated />
using System;
using Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using api.Data;
#nullable disable
@@ -17,12 +17,12 @@ namespace api.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Api.Models.Asset", b =>
modelBuilder.Entity("api.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -61,7 +61,7 @@ namespace api.Migrations
b.ToTable("Assets");
});
modelBuilder.Entity("Api.Models.Domain", b =>
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -100,7 +100,7 @@ namespace api.Migrations
b.ToTable("Domains");
});
modelBuilder.Entity("Api.Models.Event", b =>
modelBuilder.Entity("api.Models.Event", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -164,7 +164,7 @@ namespace api.Migrations
b.ToTable("Events");
});
modelBuilder.Entity("Api.Models.Project", b =>
modelBuilder.Entity("api.Models.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -190,7 +190,7 @@ namespace api.Migrations
b.ToTable("Projects");
});
modelBuilder.Entity("Api.Models.QRCodeDesign", b =>
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -232,10 +232,10 @@ namespace api.Migrations
b.HasIndex("WorkspaceId");
b.ToTable("QRCodeDesigns");
b.ToTable("QrCodeDesigns");
});
modelBuilder.Entity("Api.Models.ShortLink", b =>
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -298,7 +298,7 @@ namespace api.Migrations
b.ToTable("ShortLinks");
});
modelBuilder.Entity("Api.Models.User", b =>
modelBuilder.Entity("api.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -330,7 +330,7 @@ namespace api.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("Api.Models.Workspace", b =>
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@@ -361,9 +361,9 @@ namespace api.Migrations
b.ToTable("Workspaces");
});
modelBuilder.Entity("Api.Models.Asset", b =>
modelBuilder.Entity("api.Models.Asset", b =>
{
b.HasOne("Api.Models.Workspace", "Workspace")
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Assets")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -372,9 +372,9 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("Api.Models.Domain", b =>
modelBuilder.Entity("api.Models.Domain", b =>
{
b.HasOne("Api.Models.Workspace", "Workspace")
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Domains")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -383,20 +383,20 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("Api.Models.Event", b =>
modelBuilder.Entity("api.Models.Event", b =>
{
b.HasOne("Api.Models.QRCodeDesign", "QRCode")
b.HasOne("api.Models.QRCodeDesign", "QRCode")
.WithMany("Events")
.HasForeignKey("QRCodeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Api.Models.ShortLink", "ShortLink")
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("Events")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Api.Models.Workspace", "Workspace")
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Events")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -409,9 +409,9 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("Api.Models.Project", b =>
modelBuilder.Entity("api.Models.Project", b =>
{
b.HasOne("Api.Models.Workspace", "Workspace")
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("Projects")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -420,24 +420,24 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("Api.Models.QRCodeDesign", b =>
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.HasOne("Api.Models.Asset", "LogoAsset")
b.HasOne("api.Models.Asset", "LogoAsset")
.WithMany()
.HasForeignKey("LogoAssetId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Api.Models.Project", "Project")
b.HasOne("api.Models.Project", "Project")
.WithMany("QRCodeDesigns")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Api.Models.ShortLink", "ShortLink")
b.HasOne("api.Models.ShortLink", "ShortLink")
.WithMany("QRCodeDesigns")
.HasForeignKey("ShortLinkId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Api.Models.Workspace", "Workspace")
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("QRCodeDesigns")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -452,19 +452,19 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("Api.Models.ShortLink", b =>
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.HasOne("Api.Models.Domain", "Domain")
b.HasOne("api.Models.Domain", "Domain")
.WithMany("ShortLinks")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Api.Models.Project", "Project")
b.HasOne("api.Models.Project", "Project")
.WithMany("ShortLinks")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Api.Models.Workspace", "Workspace")
b.HasOne("api.Models.Workspace", "Workspace")
.WithMany("ShortLinks")
.HasForeignKey("WorkspaceId")
.OnDelete(DeleteBehavior.Cascade)
@@ -477,9 +477,9 @@ namespace api.Migrations
b.Navigation("Workspace");
});
modelBuilder.Entity("Api.Models.Workspace", b =>
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.HasOne("Api.Models.User", "Owner")
b.HasOne("api.Models.User", "Owner")
.WithMany("Workspaces")
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.Cascade)
@@ -488,36 +488,36 @@ namespace api.Migrations
b.Navigation("Owner");
});
modelBuilder.Entity("Api.Models.Domain", b =>
modelBuilder.Entity("api.Models.Domain", b =>
{
b.Navigation("ShortLinks");
});
modelBuilder.Entity("Api.Models.Project", b =>
modelBuilder.Entity("api.Models.Project", b =>
{
b.Navigation("QRCodeDesigns");
b.Navigation("ShortLinks");
});
modelBuilder.Entity("Api.Models.QRCodeDesign", b =>
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
{
b.Navigation("Events");
});
modelBuilder.Entity("Api.Models.ShortLink", b =>
modelBuilder.Entity("api.Models.ShortLink", b =>
{
b.Navigation("Events");
b.Navigation("QRCodeDesigns");
});
modelBuilder.Entity("Api.Models.User", b =>
modelBuilder.Entity("api.Models.User", b =>
{
b.Navigation("Workspaces");
});
modelBuilder.Entity("Api.Models.Workspace", b =>
modelBuilder.Entity("api.Models.Workspace", b =>
{
b.Navigation("Assets");

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public enum AssetType
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public enum DomainStatus
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public enum EventType
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public class Project
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public class QRCodeDesign
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public enum ShortLinkStatus
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public class User
{

View File

@@ -1,4 +1,4 @@
namespace Api.Models;
namespace api.Models;
public enum WorkspacePlan
{

View File

@@ -1,13 +1,39 @@
using Api.Data;
using System.Text;
using api.Data;
using api.Features.Auth.Settings;
using FastEndpoints;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
// 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 =>
{
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();
@@ -25,4 +51,9 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseFastEndpoints();
app.Run();

View File

@@ -7,8 +7,15 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="api.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FastEndpoints" Version="7.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.2">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -7,5 +7,8 @@
},
"ConnectionStrings": {
"PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!"
},
"Jwt": {
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!"
}
}

View File

@@ -8,5 +8,11 @@
"AllowedHosts": "*",
"ConnectionStrings": {
"PostgresConnection": ""
},
"Jwt": {
"Secret": "",
"Issuer": "TrakQR",
"Audience": "TrakQR",
"ExpirationMinutes": 60
}
}

View File

@@ -0,0 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APostgreSqlBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002Econfig_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fcdd0beaf7beaf8366c0862f34fe40da30911084d957625ab31577851ee8cae7_003FPostgreSqlBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=f24d9dca_002Dcc3a_002D42e4_002D8e9d_002D00aa5709be91/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &amp;lt;api.Tests&amp;gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;
&lt;Project Location="/home/jbourdon/repos/trakqr/src/api.Tests" Presentation="&amp;lt;api.Tests&amp;gt;" /&gt;
&lt;/SessionState&gt;</s:String></wpf:ResourceDictionary>

4
src/src.slnx Normal file
View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="api.Tests/api.Tests.csproj" />
<Project Path="api/api.csproj" />
</Solution>