feat(auth): adds basic endpoints: register, login, forgot password, reset password
This commit is contained in:
107
docs/architecture.md
Normal file
107
docs/architecture.md
Normal 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
13
src/.idea/.idea.src/.idea/.gitignore
generated
vendored
Normal 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
4
src/.idea/.idea.src/.idea/encodings.xml
generated
Normal 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>
|
||||||
8
src/.idea/.idea.src/.idea/indexLayout.xml
generated
Normal file
8
src/.idea/.idea.src/.idea/indexLayout.xml
generated
Normal 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
6
src/.idea/.idea.src/.idea/vcs.xml
generated
Normal 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
482
src/api.Tests/.gitignore
vendored
Normal 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
|
||||||
50
src/api.Tests/ApiWebApplicationFactory.cs
Normal file
50
src/api.Tests/ApiWebApplicationFactory.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/api.Tests/AuthControllerTests.cs
Normal file
225
src/api.Tests/AuthControllerTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/api.Tests/api.Tests.csproj
Normal file
28
src/api.Tests/api.Tests.csproj
Normal 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>
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
using Api.Models;
|
using api.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
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<User> Users => Set<User>();
|
||||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||||
public DbSet<Project> Projects => Set<Project>();
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
public DbSet<Domain> Domains => Set<Domain>();
|
public DbSet<Domain> Domains => Set<Domain>();
|
||||||
public DbSet<ShortLink> ShortLinks => Set<ShortLink>();
|
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<Event> Events => Set<Event>();
|
||||||
public DbSet<Asset> Assets => Set<Asset>();
|
public DbSet<Asset> Assets => Set<Asset>();
|
||||||
|
|
||||||
|
|||||||
15
src/api/Features/Auth/Common/AuthResponses.cs
Normal file
15
src/api/Features/Auth/Common/AuthResponses.cs
Normal 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);
|
||||||
55
src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs
Normal file
55
src/api/Features/Auth/Endpoints/ForgotPasswordEndpoint.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/api/Features/Auth/Endpoints/LoginEndpoint.cs
Normal file
85
src/api/Features/Auth/Endpoints/LoginEndpoint.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/api/Features/Auth/Endpoints/RegisterEndpoint.cs
Normal file
114
src/api/Features/Auth/Endpoints/RegisterEndpoint.cs
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs
Normal file
46
src/api/Features/Auth/Endpoints/ResetPasswordEndpoint.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/api/Features/Auth/Settings/JwtSettings.cs
Normal file
9
src/api/Features/Auth/Settings/JwtSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Api.Data;
|
using api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Api.Data;
|
using api.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|||||||
540
src/api/Migrations/20260127205418_RefactorAuth.Designer.cs
generated
Normal file
540
src/api/Migrations/20260127205418_RefactorAuth.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/api/Migrations/20260127205418_RefactorAuth.cs
Normal file
204
src/api/Migrations/20260127205418_RefactorAuth.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Api.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using api.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -17,12 +17,12 @@ namespace api.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "10.0.2")
|
.HasAnnotation("ProductVersion", "10.0.0")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Asset", b =>
|
modelBuilder.Entity("api.Models.Asset", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -61,7 +61,7 @@ namespace api.Migrations
|
|||||||
b.ToTable("Assets");
|
b.ToTable("Assets");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Domain", b =>
|
modelBuilder.Entity("api.Models.Domain", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -100,7 +100,7 @@ namespace api.Migrations
|
|||||||
b.ToTable("Domains");
|
b.ToTable("Domains");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Event", b =>
|
modelBuilder.Entity("api.Models.Event", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -164,7 +164,7 @@ namespace api.Migrations
|
|||||||
b.ToTable("Events");
|
b.ToTable("Events");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Project", b =>
|
modelBuilder.Entity("api.Models.Project", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -190,7 +190,7 @@ namespace api.Migrations
|
|||||||
b.ToTable("Projects");
|
b.ToTable("Projects");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.QRCodeDesign", b =>
|
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -232,10 +232,10 @@ namespace api.Migrations
|
|||||||
|
|
||||||
b.HasIndex("WorkspaceId");
|
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")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -298,7 +298,7 @@ namespace api.Migrations
|
|||||||
b.ToTable("ShortLinks");
|
b.ToTable("ShortLinks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.User", b =>
|
modelBuilder.Entity("api.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -330,7 +330,7 @@ namespace api.Migrations
|
|||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Workspace", b =>
|
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -361,9 +361,9 @@ namespace api.Migrations
|
|||||||
b.ToTable("Workspaces");
|
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")
|
.WithMany("Assets")
|
||||||
.HasForeignKey("WorkspaceId")
|
.HasForeignKey("WorkspaceId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -372,9 +372,9 @@ namespace api.Migrations
|
|||||||
b.Navigation("Workspace");
|
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")
|
.WithMany("Domains")
|
||||||
.HasForeignKey("WorkspaceId")
|
.HasForeignKey("WorkspaceId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -383,20 +383,20 @@ namespace api.Migrations
|
|||||||
b.Navigation("Workspace");
|
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")
|
.WithMany("Events")
|
||||||
.HasForeignKey("QRCodeId")
|
.HasForeignKey("QRCodeId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Api.Models.ShortLink", "ShortLink")
|
b.HasOne("api.Models.ShortLink", "ShortLink")
|
||||||
.WithMany("Events")
|
.WithMany("Events")
|
||||||
.HasForeignKey("ShortLinkId")
|
.HasForeignKey("ShortLinkId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("Api.Models.Workspace", "Workspace")
|
b.HasOne("api.Models.Workspace", "Workspace")
|
||||||
.WithMany("Events")
|
.WithMany("Events")
|
||||||
.HasForeignKey("WorkspaceId")
|
.HasForeignKey("WorkspaceId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -409,9 +409,9 @@ namespace api.Migrations
|
|||||||
b.Navigation("Workspace");
|
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")
|
.WithMany("Projects")
|
||||||
.HasForeignKey("WorkspaceId")
|
.HasForeignKey("WorkspaceId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -420,24 +420,24 @@ namespace api.Migrations
|
|||||||
b.Navigation("Workspace");
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("LogoAssetId")
|
.HasForeignKey("LogoAssetId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Api.Models.Project", "Project")
|
b.HasOne("api.Models.Project", "Project")
|
||||||
.WithMany("QRCodeDesigns")
|
.WithMany("QRCodeDesigns")
|
||||||
.HasForeignKey("ProjectId")
|
.HasForeignKey("ProjectId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Api.Models.ShortLink", "ShortLink")
|
b.HasOne("api.Models.ShortLink", "ShortLink")
|
||||||
.WithMany("QRCodeDesigns")
|
.WithMany("QRCodeDesigns")
|
||||||
.HasForeignKey("ShortLinkId")
|
.HasForeignKey("ShortLinkId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Api.Models.Workspace", "Workspace")
|
b.HasOne("api.Models.Workspace", "Workspace")
|
||||||
.WithMany("QRCodeDesigns")
|
.WithMany("QRCodeDesigns")
|
||||||
.HasForeignKey("WorkspaceId")
|
.HasForeignKey("WorkspaceId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -452,19 +452,19 @@ namespace api.Migrations
|
|||||||
b.Navigation("Workspace");
|
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")
|
.WithMany("ShortLinks")
|
||||||
.HasForeignKey("DomainId")
|
.HasForeignKey("DomainId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Api.Models.Project", "Project")
|
b.HasOne("api.Models.Project", "Project")
|
||||||
.WithMany("ShortLinks")
|
.WithMany("ShortLinks")
|
||||||
.HasForeignKey("ProjectId")
|
.HasForeignKey("ProjectId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("Api.Models.Workspace", "Workspace")
|
b.HasOne("api.Models.Workspace", "Workspace")
|
||||||
.WithMany("ShortLinks")
|
.WithMany("ShortLinks")
|
||||||
.HasForeignKey("WorkspaceId")
|
.HasForeignKey("WorkspaceId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -477,9 +477,9 @@ namespace api.Migrations
|
|||||||
b.Navigation("Workspace");
|
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")
|
.WithMany("Workspaces")
|
||||||
.HasForeignKey("OwnerUserId")
|
.HasForeignKey("OwnerUserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -488,36 +488,36 @@ namespace api.Migrations
|
|||||||
b.Navigation("Owner");
|
b.Navigation("Owner");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Domain", b =>
|
modelBuilder.Entity("api.Models.Domain", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("ShortLinks");
|
b.Navigation("ShortLinks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Project", b =>
|
modelBuilder.Entity("api.Models.Project", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("QRCodeDesigns");
|
b.Navigation("QRCodeDesigns");
|
||||||
|
|
||||||
b.Navigation("ShortLinks");
|
b.Navigation("ShortLinks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.QRCodeDesign", b =>
|
modelBuilder.Entity("api.Models.QRCodeDesign", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Events");
|
b.Navigation("Events");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.ShortLink", b =>
|
modelBuilder.Entity("api.Models.ShortLink", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Events");
|
b.Navigation("Events");
|
||||||
|
|
||||||
b.Navigation("QRCodeDesigns");
|
b.Navigation("QRCodeDesigns");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.User", b =>
|
modelBuilder.Entity("api.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Workspaces");
|
b.Navigation("Workspaces");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Api.Models.Workspace", b =>
|
modelBuilder.Entity("api.Models.Workspace", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Assets");
|
b.Navigation("Assets");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public enum AssetType
|
public enum AssetType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public enum DomainStatus
|
public enum DomainStatus
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public enum EventType
|
public enum EventType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public class Project
|
public class Project
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public class QRCodeDesign
|
public class QRCodeDesign
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public enum ShortLinkStatus
|
public enum ShortLinkStatus
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public class User
|
public class User
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Api.Models;
|
namespace api.Models;
|
||||||
|
|
||||||
public enum WorkspacePlan
|
public enum WorkspacePlan
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
|
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();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -25,4 +51,9 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.UseFastEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -7,8 +7,15 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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.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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -7,5 +7,8 @@
|
|||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!"
|
"PostgresConnection": "Host=localhost;Port=5400;Database=trakqr;Username=sa;Password=P@ssword123!"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "dev-secret-key-min-32-characters-long-for-hmac256!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,11 @@
|
|||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"PostgresConnection": ""
|
"PostgresConnection": ""
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Secret": "",
|
||||||
|
"Issuer": "TrakQR",
|
||||||
|
"Audience": "TrakQR",
|
||||||
|
"ExpirationMinutes": 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/src.sln.DotSettings.user
Normal file
5
src/src.sln.DotSettings.user
Normal 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"><SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;api.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||||
|
<Project Location="/home/jbourdon/repos/trakqr/src/api.Tests" Presentation="&lt;api.Tests&gt;" />
|
||||||
|
</SessionState></s:String></wpf:ResourceDictionary>
|
||||||
4
src/src.slnx
Normal file
4
src/src.slnx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="api.Tests/api.Tests.csproj" />
|
||||||
|
<Project Path="api/api.csproj" />
|
||||||
|
</Solution>
|
||||||
Reference in New Issue
Block a user