diff --git a/docs/pipeline/diagrams/biergarten-weizen-theme.puml b/docs/pipeline/diagrams/biergarten-weizen-theme.puml
index b31305d6..b85f22c9 100644
--- a/docs/pipeline/diagrams/biergarten-weizen-theme.puml
+++ b/docs/pipeline/diagrams/biergarten-weizen-theme.puml
@@ -32,3 +32,24 @@ skinparam interfaceFontColor #14180C
skinparam enumBackgroundColor #E4E6D8
skinparam enumBorderColor #4A5837
skinparam enumFontColor #14180C
+skinparam ParticipantBackgroundColor #EBECE3
+skinparam ParticipantBorderColor #4A5837
+skinparam ParticipantFontColor #14180C
+skinparam ActorBackgroundColor #DBEEDD
+skinparam ActorBorderColor #4A5837
+skinparam ActorFontColor #14180C
+skinparam LifeLineBorderColor #4A5837
+skinparam LifeLineBackgroundColor #F1F3EA
+skinparam DatabaseBackgroundColor #EBECE3
+skinparam DatabaseBorderColor #4A5837
+skinparam DatabaseFontColor #14180C
+skinparam SequenceGroupBackgroundColor #DBEEDD
+skinparam SequenceGroupBorderColor #4A5837
+skinparam SequenceGroupFontColor #14180C
+skinparam SequenceGroupBodyBackgroundColor transparent
+skinparam SequenceDividerBackgroundColor #DBEEDD
+skinparam SequenceDividerBorderColor #4A5837
+skinparam SequenceDividerFontColor #14180C
+skinparam SequenceBoxBackgroundColor #F1F3EA
+skinparam SequenceBoxBorderColor #4A5837
+skinparam SequenceBoxFontColor #14180C
diff --git a/docs/pipeline/diagrams/current/activity.puml b/docs/pipeline/diagrams/current/activity.puml
index 998a742b..cd4803d7 100644
--- a/docs/pipeline/diagrams/current/activity.puml
+++ b/docs/pipeline/diagrams/current/activity.puml
@@ -1,22 +1,9 @@
@startuml
+!include ../biergarten-weizen-theme.puml
skinparam style strictuml
-skinparam defaultFontName "DM Sans"
skinparam defaultFontSize 14
-skinparam titleFontName "Volkhov"
skinparam titleFontSize 20
-skinparam backgroundColor #FAFCF9
-skinparam defaultFontColor #28342A
-skinparam titleFontColor #28342A
-skinparam ArrowColor #628A5B
-skinparam NoteBackgroundColor #EAF0E8
-skinparam NoteBorderColor #547461
-skinparam ActivityBackgroundColor #FAFCF9
-skinparam ActivityBorderColor #547461
-skinparam ActivityDiamondBackgroundColor #FAFCF9
-skinparam ActivityDiamondBorderColor #628A5B
skinparam ActivityBarColor #628A5B
-skinparam SwimlaneBorderColor #547461
-skinparam SwimlaneBorderThickness 0.3
title The Biergarten Data Pipeline (Current — Synchronous Data Path)
diff --git a/docs/pipeline/diagrams/current/class.puml b/docs/pipeline/diagrams/current/class.puml
index 935274a6..f7a8d482 100644
--- a/docs/pipeline/diagrams/current/class.puml
+++ b/docs/pipeline/diagrams/current/class.puml
@@ -1,27 +1,8 @@
@startuml
+!include ../biergarten-weizen-theme.puml
skinparam style strictuml
-skinparam defaultFontName "DM Sans"
skinparam defaultFontSize 14
-skinparam titleFontName "Volkhov"
skinparam titleFontSize 20
-skinparam backgroundColor #FAFCF9
-skinparam defaultFontColor #28342A
-skinparam titleFontColor #28342A
-skinparam ArrowColor #628A5B
-
-skinparam class {
- BackgroundColor #FAFCF9
- HeaderBackgroundColor #EAF0E8
- BorderColor #547461
- ArrowColor #628A5B
- FontColor #28342A
-}
-
-skinparam note {
- BackgroundColor #EAF0E8
- BorderColor #547461
- FontColor #28342A
-}
title The Biergarten Data Pipeline - Class Diagram
diff --git a/docs/pipeline/diagrams/current/output/activity.svg b/docs/pipeline/diagrams/current/output/activity.svg
deleted file mode 100644
index 858ca813..00000000
--- a/docs/pipeline/diagrams/current/output/activity.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/pipeline/diagrams/current/output/class.svg b/docs/pipeline/diagrams/current/output/class.svg
deleted file mode 100644
index b4751c3a..00000000
--- a/docs/pipeline/diagrams/current/output/class.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/pipeline/diagrams/planned/output/biergarten_activity.svg b/docs/pipeline/diagrams/planned/output/biergarten_activity.svg
deleted file mode 100644
index e565241d..00000000
--- a/docs/pipeline/diagrams/planned/output/biergarten_activity.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/pipeline/diagrams/planned/output/class.svg b/docs/pipeline/diagrams/planned/output/class.svg
deleted file mode 100644
index f796ee6e..00000000
--- a/docs/pipeline/diagrams/planned/output/class.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/website/diagrams-out/architecture.svg b/docs/website/diagrams-out/architecture.svg
deleted file mode 100644
index b07f8b34..00000000
--- a/docs/website/diagrams-out/architecture.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/website/diagrams-out/authentication-flow.svg b/docs/website/diagrams-out/authentication-flow.svg
deleted file mode 100644
index e90a7d3e..00000000
--- a/docs/website/diagrams-out/authentication-flow.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/website/diagrams-out/database-schema.svg b/docs/website/diagrams-out/database-schema.svg
deleted file mode 100644
index 2c9e70c5..00000000
--- a/docs/website/diagrams-out/database-schema.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/website/diagrams-out/deployment.svg b/docs/website/diagrams-out/deployment.svg
deleted file mode 100644
index a0ca5751..00000000
--- a/docs/website/diagrams-out/deployment.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/website/diagrams-src/architecture.puml b/docs/website/diagrams-src/architecture.puml
index e634f6c2..80405957 100644
--- a/docs/website/diagrams-src/architecture.puml
+++ b/docs/website/diagrams-src/architecture.puml
@@ -1,12 +1,9 @@
@startuml architecture
-!theme plain
-skinparam backgroundColor #FFFFFF
-skinparam defaultFontName Arial
-skinparam packageStyle rectangle
+!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
title The Biergarten App - Vertical Slice Architecture
-package "API.Core (thin host)" #E3F2FD {
+package "API.Core (thin host)" #F1F3EA {
[API.Core\nASP.NET Core Web API host] as API
note right of API
- Program.cs wiring only
@@ -18,7 +15,7 @@ package "API.Core (thin host)" #E3F2FD {
end note
}
-package "Feature Slices" #F3E5F5 {
+package "Feature Slices" #DBEEDD {
[Features.Users] as UsersSlice
[Features.Breweries] as BrewerySlice
[Features.Emails] as EmailsSlice
@@ -50,7 +47,7 @@ package "Feature Slices" #F3E5F5 {
end note
}
-package "Shared" #FCE4EC {
+package "Shared" #E4E6D8 {
[Shared.Contracts] as SharedContracts
[Shared.Application] as SharedApp
note right of SharedApp
@@ -62,7 +59,7 @@ package "Shared" #FCE4EC {
end note
}
-package "Infrastructure Layer" #FFF3E0 {
+package "Infrastructure Layer" #EBECE3 {
[Infrastructure.Sql] as Sql
[Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash
@@ -70,7 +67,7 @@ package "Infrastructure Layer" #FFF3E0 {
[Infrastructure.Email.Templates] as EmailTemplates
}
-package "Domain Layer" #E8F5E9 {
+package "Domain Layer" #CBD2B5 {
[Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions
note right of Domain
diff --git a/docs/website/diagrams-src/auth/confirm-user.puml b/docs/website/diagrams-src/auth/confirm-user.puml
new file mode 100644
index 00000000..2692fbd0
--- /dev/null
+++ b/docs/website/diagrams-src/auth/confirm-user.puml
@@ -0,0 +1,109 @@
+@startuml confirm-user
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Confirm User
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "ConfirmUserHandler" as ConfirmHandler
+participant "UserManager" as UserMgr
+participant "TokenService" as TokenSvc
+end box
+box "Infrastructure Layer" #EBECE3
+participant "JWT\nInfrastructure" as JWT
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: POST /api/auth/confirm?token=\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(ConfirmUserCommand(token))
+activate Mediator
+
+Mediator -> ConfirmHandler: Handle(command)
+activate ConfirmHandler
+
+ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token)
+activate TokenSvc
+TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId
+activate JWT
+JWT --> TokenSvc: ValidatedToken or throws
+deactivate JWT
+TokenSvc --> ConfirmHandler: ValidatedToken
+deactivate TokenSvc
+
+alt Token invalid or expired
+ ConfirmHandler -> Mediator: throw UnauthorizedException
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+else Token valid
+
+ ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId)
+ activate UserMgr
+ UserMgr -> UserStore: FindByIdAsync(userId)
+ activate UserStore
+ UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
+ activate DB
+ DB --> UserStore: row or null
+ deactivate DB
+ UserStore --> UserMgr: ApplicationUser or null
+ deactivate UserStore
+ UserMgr --> ConfirmHandler: ApplicationUser or null
+ deactivate UserMgr
+
+ alt User not found
+ ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found"
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+ else User found
+
+ ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true)
+ note right of UserStore
+ IUserEmailStore is injected
+ directly into the handler (not via UserManager),
+ setting the confirmed flag in-memory here.
+ end note
+
+ ConfirmHandler -> UserMgr: UpdateAsync(user)
+ activate UserMgr
+ UserMgr -> UserStore: UpdateAsync(user)
+ activate UserStore
+ UserStore -> DB: UPDATE dbo.UserAccount SET ...
+ UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
+ note right of UserStore
+ Idempotent: skipped (duplicate-key
+ exception swallowed) if a verification
+ row already exists.
+ end note
+ UserStore --> UserMgr: IdentityResult.Success
+ deactivate UserStore
+ UserMgr --> ConfirmHandler: IdentityResult
+ deactivate UserMgr
+
+ ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
+ deactivate ConfirmHandler
+ Mediator --> API: ConfirmationPayload
+ deactivate Mediator
+
+ API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/delete-account.puml b/docs/website/diagrams-src/auth/delete-account.puml
new file mode 100644
index 00000000..90cf4370
--- /dev/null
+++ b/docs/website/diagrams-src/auth/delete-account.puml
@@ -0,0 +1,91 @@
+@startuml delete-account
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Delete Account
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "DeleteAccountHandler" as DeleteAccountHandler
+participant "UserManager" as UserMgr
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: DELETE /api/auth/account\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId()))
+activate Mediator
+
+Mediator -> DeleteAccountHandler: Handle(command)
+activate DeleteAccountHandler
+
+DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId)
+activate UserMgr
+UserMgr -> UserStore: FindByIdAsync(userAccountId)
+UserStore --> UserMgr: ApplicationUser or null
+UserMgr --> DeleteAccountHandler: ApplicationUser or null
+deactivate UserMgr
+
+alt Account not found
+ DeleteAccountHandler -> Mediator: throw NotFoundException
+ Mediator -> API: propagate
+ API -> User: 404 Not Found
+else Account found
+
+ DeleteAccountHandler -> UserMgr: DeleteAsync(user)
+ activate UserMgr
+ UserMgr -> UserStore: DeleteAsync(user)
+ activate UserStore
+ UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id
+ note right of UserStore
+ UserCredential/UserVerification/UserAvatar
+ cascade on delete. Posts, comments, photos,
+ and follows use ON DELETE NO ACTION - if any
+ reference this account, the DELETE throws a
+ foreign key violation (SQL error 547).
+ end note
+
+ alt Foreign key violation (dependent records exist)
+ UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords)
+ else Delete succeeds
+ UserStore --> UserMgr: IdentityResult.Success
+ end
+ deactivate UserStore
+ UserMgr --> DeleteAccountHandler: IdentityResult
+ deactivate UserMgr
+
+ alt Account has dependent records
+ DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows."
+ Mediator -> API: propagate
+ API -> User: 409 Conflict
+ else !result.Succeeded (other failure)
+ DeleteAccountHandler -> Mediator: throw ConflictException
+ Mediator -> API: propagate
+ API -> User: 409 Conflict
+ else result.Succeeded
+ DeleteAccountHandler --> Mediator: (void)
+ deactivate DeleteAccountHandler
+ Mediator --> API: (void)
+ deactivate Mediator
+ API -> User: 200 OK\n{message: "Account deleted successfully."}
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/error-handling.puml b/docs/website/diagrams-src/auth/error-handling.puml
new file mode 100644
index 00000000..ed99d309
--- /dev/null
+++ b/docs/website/diagrams-src/auth/error-handling.puml
@@ -0,0 +1,30 @@
+@startuml error-handling
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Error Handling (Global Exception Filter)
+
+participant "API\nController" as API
+
+note over API
+ API.Core's GlobalExceptionFilter catches:
+ - FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
+ - ConflictException -> 409 Conflict
+ - NotFoundException -> 404 Not Found
+ - UnauthorizedException -> 401 Unauthorized
+ - ForbiddenException -> 403 Forbidden
+ - Domain.Exceptions.ValidationException -> 400 Bad Request
+ - Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
+ - All others -> 500 Internal Server Error
+end note
+
+note over API
+ Applies uniformly across every authentication
+ endpoint (registration, login, refresh, confirm,
+ resend, update-username/email/password/profile,
+ delete-account) - see the per-flow diagrams for
+ where each exception type is thrown.
+end note
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/login.puml b/docs/website/diagrams-src/auth/login.puml
new file mode 100644
index 00000000..d3c2fea3
--- /dev/null
+++ b/docs/website/diagrams-src/auth/login.puml
@@ -0,0 +1,129 @@
+@startuml login
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Login
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "LoginHandler" as LoginHandler
+participant "UserManager" as UserMgr
+participant "TokenService" as TokenSvc
+end box
+box "Infrastructure Layer" #EBECE3
+participant "Argon2PasswordHasher" as Argon2
+participant "JWT\nInfrastructure" as JWT
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: POST /api/auth/login\n{username, password}
+activate API
+
+API -> Mediator: Send(LoginCommand)
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs LoginValidator:
+ username and password must both be non-empty
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
+else Validation succeeds
+
+ Mediator -> LoginHandler: Handle(command)
+ activate LoginHandler
+
+ LoginHandler -> UserMgr: FindByNameAsync(username)
+ activate UserMgr
+ UserMgr -> UserStore: FindByNameAsync(username)
+ activate UserStore
+ UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
+ activate DB
+ DB --> UserStore: UserAccount row or null
+ deactivate DB
+ UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
+ UserStore --> UserMgr: ApplicationUser or null
+ deactivate UserStore
+ UserMgr --> LoginHandler: ApplicationUser or null
+ deactivate UserMgr
+
+ alt User not found
+ LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+ else User found
+
+ LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
+ activate UserMgr
+ note right of UserMgr
+ HasPasswordAsync short-circuits to false
+ (no active UserCredential was hydrated)
+ without calling the hasher; otherwise
+ delegates to Argon2PasswordHasher.
+ end note
+ UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
+ activate Argon2
+ note right of Argon2
+ Delegates to IPasswordInfrastructure.Verify:
+ 1. Split stored hash: "salt:hash"
+ 2. Hash provided password with the same salt
+ 3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
+ end note
+ Argon2 --> UserMgr: Success/Failed
+ deactivate Argon2
+ UserMgr --> LoginHandler: true/false
+ deactivate UserMgr
+
+ alt Password invalid
+ LoginHandler -> Mediator: throw UnauthorizedException
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+ else Password valid
+
+ LoginHandler -> TokenSvc: GenerateAccessToken(user)
+ activate TokenSvc
+ TokenSvc -> JWT: GenerateJwt(...)
+ activate JWT
+ JWT --> TokenSvc: Access Token
+ deactivate JWT
+ TokenSvc --> LoginHandler: Access Token
+ deactivate TokenSvc
+
+ LoginHandler -> TokenSvc: GenerateRefreshToken(user)
+ activate TokenSvc
+ TokenSvc -> JWT: GenerateJwt(...)
+ activate JWT
+ JWT --> TokenSvc: Refresh Token
+ deactivate JWT
+ TokenSvc --> LoginHandler: Refresh Token
+ deactivate TokenSvc
+
+ LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
+ deactivate LoginHandler
+ Mediator --> API: LoginPayload
+ deactivate Mediator
+
+ API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
+ end
+ end
+ end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/refresh-token.puml b/docs/website/diagrams-src/auth/refresh-token.puml
new file mode 100644
index 00000000..aef6a468
--- /dev/null
+++ b/docs/website/diagrams-src/auth/refresh-token.puml
@@ -0,0 +1,108 @@
+@startuml refresh-token
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Refresh Token
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "RefreshTokenHandler" as RefreshHandler
+participant "UserManager" as UserMgr
+participant "TokenService" as TokenSvc
+end box
+box #EBECE3
+participant "JWT\nInfrastructure" as JWT
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token:
+activate API
+
+API -> Mediator: Send(RefreshTokenCommand(refreshToken))
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs RefreshTokenValidator:
+ refreshToken must be non-empty. Anonymous
+ endpoint - no Bearer token required.
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
+else Validation succeeds
+
+ Mediator -> RefreshHandler: Handle(command)
+ activate RefreshHandler
+
+ RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken)
+ activate TokenSvc
+
+ TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken)
+ activate JWT
+ note right of JWT
+ Validates signature/expiry against the
+ refresh token secret, extracts userId/
+ username claims.
+ end note
+ JWT --> TokenSvc: ValidatedToken or throws
+ deactivate JWT
+
+ alt Token invalid or expired
+ TokenSvc -> RefreshHandler: throw UnauthorizedException
+ RefreshHandler -> Mediator: propagate
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+ else Token valid
+
+ TokenSvc -> UserMgr: FindByIdAsync(validated.UserId)
+ activate UserMgr
+ UserMgr -> UserStore: FindByIdAsync(userId)
+ activate UserStore
+ UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
+ activate DB
+ DB --> UserStore: row or null
+ deactivate DB
+ UserStore --> UserMgr: ApplicationUser or null
+ deactivate UserStore
+ UserMgr --> TokenSvc: ApplicationUser or null
+ deactivate UserMgr
+
+ alt User not found
+ TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found"
+ RefreshHandler -> Mediator: propagate
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+ else User found
+ TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
+ JWT --> TokenSvc: new access + refresh tokens
+ TokenSvc --> RefreshHandler: RefreshTokenResult
+ deactivate TokenSvc
+
+ RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
+ deactivate RefreshHandler
+ Mediator --> API: LoginPayload
+ deactivate Mediator
+
+ API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
+ end
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/registration.puml b/docs/website/diagrams-src/auth/registration.puml
new file mode 100644
index 00000000..c4ef5db9
--- /dev/null
+++ b/docs/website/diagrams-src/auth/registration.puml
@@ -0,0 +1,241 @@
+@startuml registration
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Registration
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "RegisterUserHandler" as RegHandler
+participant "UserManager" as UserMgr
+participant "TokenService" as TokenSvc
+end box
+box "Infrastructure Layer" #EBECE3
+participant "Argon2PasswordHasher" as Argon2
+participant "JWT\nInfrastructure" as JWT
+participant "Email\nProvider" as SMTP
+participant "Template\nProvider" as Template
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
+activate API
+
+API -> Mediator: Send(RegisterUserCommand)
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs RegisterUserValidator
+ (FluentValidation) before the handler:
+ - Username: 3-64 chars, alphanumeric + [._-]
+ - Email: valid format, max 128 chars
+ - Password: min 8 chars, uppercase,\n lowercase, number, special char
+ - DateOfBirth: must be 19+ years old
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
+else Validation succeeds
+ Mediator -> RegHandler: Handle(command)
+ activate RegHandler
+
+ RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
+
+ RegHandler -> UserMgr: CreateAsync(user, password)
+ activate UserMgr
+
+ UserMgr -> Argon2: HashPassword(user, password)
+ activate Argon2
+ note right of Argon2
+ Delegates to IPasswordInfrastructure.Hash:
+ Argon2id parameters:
+ - Salt: 16 bytes (128-bit)
+ - Memory: 64MB
+ - Iterations: 4
+ - Parallelism: CPU count
+ - Hash output: 32 bytes
+ end note
+ Argon2 --> UserMgr: "base64(salt):base64(hash)"
+ deactivate Argon2
+
+ UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
+ note right of UserStore
+ user.Id is still empty at this point -
+ only sets the in-memory hash. CreateAsync
+ writes it to dbo.UserCredential itself.
+ end note
+
+ UserMgr -> UserStore: FindByNameAsync(username)
+ activate UserStore
+ UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
+ activate DB
+ DB --> UserStore: null (user doesn't exist)
+ deactivate DB
+ deactivate UserStore
+
+ UserMgr -> UserStore: FindByEmailAsync(email)
+ activate UserStore
+ UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
+ activate DB
+ DB --> UserStore: null (email doesn't exist)
+ deactivate DB
+ deactivate UserStore
+ note right of UserMgr
+ Default UserManager validators check
+ uniqueness before CreateAsync is called;
+ DapperUserStore.CreateAsync also guards
+ against a genuine race via a caught
+ duplicate-key SqlException (2601/2627).
+ end note
+
+ UserMgr -> UserStore: CreateAsync(user)
+ activate UserStore
+
+ UserStore -> DB: BEGIN TRANSACTION
+ activate DB
+ UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
+ DB --> UserStore: UserAccountId (GUID)
+ UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
+ UserStore -> DB: COMMIT TRANSACTION
+ deactivate DB
+ note right of UserStore
+ Both statements run via Dapper's
+ ExecuteScalarAsync/ExecuteAsync against
+ a single DbTransaction, not a stored
+ procedure - no manual parameter binding
+ end note
+
+ UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
+ deactivate UserStore
+
+ UserMgr --> RegHandler: IdentityResult
+ deactivate UserMgr
+
+ alt !result.Succeeded
+ RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
+ Mediator -> API: propagate
+ API -> User: 409 Conflict\n"Username or email already exists"
+ else result.Succeeded
+
+ RegHandler -> TokenSvc: GenerateAccessToken(user)
+ activate TokenSvc
+ TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
+ activate JWT
+ note right of JWT
+ JWT Configuration:
+ - Algorithm: HS256
+ - Expires: 1 hour
+ - Claims:
+ * sub: userId
+ * unique_name: username
+ * iat / jti
+ end note
+ JWT -> JWT: Create JWT with claims
+ JWT -> JWT: Sign with secret key
+ JWT --> TokenSvc: Access Token
+ deactivate JWT
+ TokenSvc --> RegHandler: Access Token
+ deactivate TokenSvc
+
+ RegHandler -> TokenSvc: GenerateRefreshToken(user)
+ activate TokenSvc
+ TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
+ activate JWT
+ note right of JWT
+ Refresh Token:
+ - Expires: 21 days (504 hours)
+ - Same structure as access token,\n signed with a separate secret
+ end note
+ JWT --> TokenSvc: Refresh Token
+ deactivate JWT
+ TokenSvc --> RegHandler: Refresh Token
+ deactivate TokenSvc
+
+ RegHandler -> TokenSvc: GenerateConfirmationToken(user)
+ activate TokenSvc
+ TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
+ activate JWT
+ note right of JWT
+ Confirmation Token:
+ - Expires: 30 minutes (0.5 hours)
+ - Signed with a third, separate secret
+ end note
+ JWT --> TokenSvc: Confirmation Token
+ deactivate JWT
+ TokenSvc --> RegHandler: Confirmation Token
+ deactivate TokenSvc
+
+ RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
+ activate Mediator
+ Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
+ note right of Mediator
+ Routed to Features.Emails'
+ SendRegistrationEmailHandler,
+ the only cross-slice call in
+ the backend
+ end note
+ activate EmailDispatcher
+
+ EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
+ activate Template
+ note right of Template
+ Razor Component:
+ - Header with branding
+ - Welcome message
+ - Confirmation button
+ - Footer
+ end note
+ Template -> Template: Render Razor component\nto HTML
+ Template --> EmailDispatcher: HTML email content
+ deactivate Template
+
+ EmailDispatcher -> SMTP: SendAsync(email, subject, body)
+ activate SMTP
+ note right of SMTP
+ SMTP Configuration (MailKit):
+ - Host/port: from env (SMTP_HOST/SMTP_PORT)
+ - TLS: StartTLS when SMTP_USE_SSL=true
+ - Auth: username/password, if configured
+ end note
+ SMTP -> SMTP: Build MIME message
+ SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
+ SMTP --> EmailDispatcher: Success / throws
+ deactivate SMTP
+
+ deactivate EmailDispatcher
+ deactivate Mediator
+
+ alt Email sent successfully
+ RegHandler -> RegHandler: emailSent = true
+ else mediator.Send throws
+ RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
+ end
+
+ RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
+ deactivate RegHandler
+ Mediator --> API: RegistrationPayload
+ deactivate Mediator
+
+ API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/resend-confirmation.puml b/docs/website/diagrams-src/auth/resend-confirmation.puml
new file mode 100644
index 00000000..8026ff96
--- /dev/null
+++ b/docs/website/diagrams-src/auth/resend-confirmation.puml
@@ -0,0 +1,97 @@
+@startuml resend-confirmation
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Resend Confirmation Email
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "ResendConfirmationEmailHandler" as ResendHandler
+participant "UserManager" as UserMgr
+participant "TokenService" as TokenSvc
+end box
+box "Infrastructure Layer" #EBECE3
+participant "Email\nProvider" as SMTP
+participant "Template\nProvider" as Template
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: POST /api/auth/confirm/resend?userId=\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(ResendConfirmationEmailCommand(userId))
+activate Mediator
+
+Mediator -> ResendHandler: Handle(command)
+activate ResendHandler
+
+ResendHandler -> UserMgr: FindByIdAsync(userId)
+activate UserMgr
+UserMgr -> UserStore: FindByIdAsync(userId)
+activate UserStore
+UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
+activate DB
+DB --> UserStore: row or null
+deactivate DB
+UserStore --> UserMgr: ApplicationUser or null
+deactivate UserStore
+UserMgr --> ResendHandler: ApplicationUser or null
+deactivate UserMgr
+
+alt User not found
+ ResendHandler -> ResendHandler: return silently\n(prevents user enumeration)
+else User found
+
+ ResendHandler -> UserMgr: IsEmailConfirmedAsync(user)
+ UserMgr --> ResendHandler: bool
+
+ alt Already confirmed
+ ResendHandler -> ResendHandler: return silently (no-op)
+ else Not yet confirmed
+
+ ResendHandler -> TokenSvc: GenerateConfirmationToken(user)
+ activate TokenSvc
+ TokenSvc --> ResendHandler: confirmation token
+ deactivate TokenSvc
+
+ ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand)
+ activate Mediator
+ Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken)
+ activate EmailDispatcher
+ EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration)
+ EmailDispatcher -> SMTP: SendAsync(email, subject, body)
+ deactivate EmailDispatcher
+ deactivate Mediator
+ end
+end
+
+ResendHandler --> Mediator: (void)
+deactivate ResendHandler
+Mediator --> API: (void)
+deactivate Mediator
+
+API -> User: 200 OK\n{message: "confirmation email has been resent"}
+note right of API
+ Always 200 OK regardless of outcome above -
+ same user-enumeration precaution as a not-found
+ user or an already-confirmed account.
+end note
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/update-email.puml b/docs/website/diagrams-src/auth/update-email.puml
new file mode 100644
index 00000000..7ddd2e46
--- /dev/null
+++ b/docs/website/diagrams-src/auth/update-email.puml
@@ -0,0 +1,93 @@
+@startuml update-email
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Update Email
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "UpdateEmailHandler" as UpdateEmailHandler
+participant "UserManager" as UserMgr
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs UpdateEmailValidator
+ (same format rules as registration)
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request
+else Validation succeeds
+
+ Mediator -> UpdateEmailHandler: Handle(command)
+ activate UpdateEmailHandler
+
+ UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId)
+ activate UserMgr
+ UserMgr -> UserStore: FindByIdAsync(userAccountId)
+ UserStore -> DB: SELECT ...
+ UserStore --> UserMgr: ApplicationUser or null
+ UserMgr --> UpdateEmailHandler: ApplicationUser or null
+ deactivate UserMgr
+
+ alt Account not found
+ UpdateEmailHandler -> Mediator: throw NotFoundException
+ Mediator -> API: propagate
+ API -> User: 404 Not Found
+ else Account found
+
+ UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail)
+ activate UserMgr
+ UserMgr -> UserStore: FindByEmailAsync(newEmail)
+ note right of UserMgr
+ Uniqueness check. Also resets
+ user.EmailConfirmed = false -
+ the caller must re-confirm via
+ POST /api/auth/confirm/resend.
+ end note
+ UserMgr -> UserStore: UpdateAsync(user)
+ UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
+ UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
+ UserStore --> UserMgr: IdentityResult
+ UserMgr --> UpdateEmailHandler: IdentityResult
+ deactivate UserMgr
+
+ alt !result.Succeeded
+ UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
+ Mediator -> API: propagate
+ API -> User: 409 Conflict
+ else result.Succeeded
+ UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
+ deactivate UpdateEmailHandler
+ Mediator --> API: UpdateEmailPayload
+ deactivate Mediator
+ API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
+ end
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/update-password.puml b/docs/website/diagrams-src/auth/update-password.puml
new file mode 100644
index 00000000..615402a0
--- /dev/null
+++ b/docs/website/diagrams-src/auth/update-password.puml
@@ -0,0 +1,111 @@
+@startuml update-password
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Update Password
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "UpdatePasswordHandler" as UpdatePasswordHandler
+participant "UserManager" as UserMgr
+end box
+box "Infrastructure Layer" #EBECE3
+participant "Argon2PasswordHasher" as Argon2
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs UpdatePasswordValidator
+ (same password strength rules as registration)
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request
+else Validation succeeds
+
+ Mediator -> UpdatePasswordHandler: Handle(command)
+ activate UpdatePasswordHandler
+
+ UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId)
+ activate UserMgr
+ UserMgr -> UserStore: FindByIdAsync(userAccountId)
+ UserStore --> UserMgr: ApplicationUser or null
+ UserMgr --> UpdatePasswordHandler: ApplicationUser or null
+ deactivate UserMgr
+
+ alt Account not found
+ UpdatePasswordHandler -> Mediator: throw NotFoundException
+ Mediator -> API: propagate
+ API -> User: 404 Not Found
+ else Account found
+
+ UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword)
+ activate UserMgr
+ UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
+ activate Argon2
+ Argon2 --> UserMgr: Success/Failed
+ deactivate Argon2
+
+ alt Current password mismatch
+ UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch)
+ else Current password matches
+ UserMgr -> Argon2: HashPassword(user, newPassword)
+ activate Argon2
+ Argon2 --> UserMgr: new hash
+ deactivate Argon2
+ UserMgr -> UserStore: SetPasswordHashAsync(user, newHash)
+ activate UserStore
+ UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
+ UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
+ note right of UserStore
+ Revoke-then-insert runs inside one
+ DbTransaction (credential rotation).
+ end note
+ UserStore --> UserMgr: (void)
+ deactivate UserStore
+ UserMgr --> UpdatePasswordHandler: IdentityResult.Success
+ end
+ deactivate UserMgr
+
+ alt Current password is incorrect
+ UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect"
+ Mediator -> API: propagate
+ API -> User: 401 Unauthorized
+ else !result.Succeeded (other failure)
+ UpdatePasswordHandler -> Mediator: throw ConflictException
+ Mediator -> API: propagate
+ API -> User: 409 Conflict
+ else result.Succeeded
+ UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow)
+ deactivate UpdatePasswordHandler
+ Mediator --> API: UpdatePasswordPayload
+ deactivate Mediator
+ API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
+ end
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/update-profile.puml b/docs/website/diagrams-src/auth/update-profile.puml
new file mode 100644
index 00000000..53421b99
--- /dev/null
+++ b/docs/website/diagrams-src/auth/update-profile.puml
@@ -0,0 +1,92 @@
+@startuml update-profile
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Update Profile
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "UpdateProfileHandler" as UpdateProfileHandler
+participant "UserManager" as UserMgr
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs UpdateProfileValidator
+ (same name/DateOfBirth rules as registration)
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request
+else Validation succeeds
+
+ Mediator -> UpdateProfileHandler: Handle(command)
+ activate UpdateProfileHandler
+
+ UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId)
+ activate UserMgr
+ UserMgr -> UserStore: FindByIdAsync(userAccountId)
+ UserStore --> UserMgr: ApplicationUser or null
+ UserMgr --> UpdateProfileHandler: ApplicationUser or null
+ deactivate UserMgr
+
+ alt Account not found
+ UpdateProfileHandler -> Mediator: throw NotFoundException
+ Mediator -> API: propagate
+ API -> User: 404 Not Found
+ else Account found
+
+ UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ...
+ note right of UpdateProfileHandler
+ First/last name and date of birth aren't
+ tracked by any Identity store interface -
+ mutated directly on the ApplicationUser,
+ then saved via UserManager.UpdateAsync.
+ end note
+
+ UpdateProfileHandler -> UserMgr: UpdateAsync(user)
+ activate UserMgr
+ UserMgr -> UserStore: UpdateAsync(user)
+ UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ...
+ UserStore --> UserMgr: IdentityResult
+ UserMgr --> UpdateProfileHandler: IdentityResult
+ deactivate UserMgr
+
+ alt !result.Succeeded
+ UpdateProfileHandler -> Mediator: throw ConflictException
+ Mediator -> API: propagate
+ API -> User: 409 Conflict
+ else result.Succeeded
+ UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth)
+ deactivate UpdateProfileHandler
+ Mediator --> API: UpdateProfilePayload
+ deactivate Mediator
+ API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
+ end
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/auth/update-username.puml b/docs/website/diagrams-src/auth/update-username.puml
new file mode 100644
index 00000000..54752d6c
--- /dev/null
+++ b/docs/website/diagrams-src/auth/update-username.puml
@@ -0,0 +1,94 @@
+@startuml update-username
+!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
+skinparam sequenceMessageAlign center
+skinparam maxMessageSize 200
+
+title User Authentication Flow - Update Username
+
+actor User
+participant "API\nController" as API
+participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
+box "Handler Layer" #F1F3EA
+participant "UpdateUsernameHandler" as UpdateUsernameHandler
+participant "UserManager" as UserMgr
+end box
+box "Repository / Cross-slice" #DBEEDD
+participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
+end box
+database "SQL Server" as DB
+
+note over UserMgr
+ ASP.NET Core Identity's UserManager
+ replaces the deleted AuthRepository: handlers
+ no longer issue SQL or call Argon2 directly,
+ they delegate to UserManager, which in turn
+ calls DapperUserStore (persistence) and
+ Argon2PasswordHasher (IPasswordHasher).
+end note
+
+User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
+activate API
+
+API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
+activate Mediator
+
+note right of Mediator
+ ValidationBehavior runs UpdateUsernameValidator
+ (same format rules as registration)
+end note
+
+alt Validation fails
+ Mediator -> API: throws FluentValidation.ValidationException
+ API -> User: 400 Bad Request
+else Validation succeeds
+
+ Mediator -> UpdateUsernameHandler: Handle(command)
+ activate UpdateUsernameHandler
+
+ UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId)
+ activate UserMgr
+ UserMgr -> UserStore: FindByIdAsync(userAccountId)
+ activate UserStore
+ UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
+ activate DB
+ DB --> UserStore: row or null
+ deactivate DB
+ UserStore --> UserMgr: ApplicationUser or null
+ deactivate UserStore
+ UserMgr --> UpdateUsernameHandler: ApplicationUser or null
+ deactivate UserMgr
+
+ alt Account not found
+ UpdateUsernameHandler -> Mediator: throw NotFoundException
+ Mediator -> API: propagate
+ API -> User: 404 Not Found
+ else Account found
+
+ UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername)
+ activate UserMgr
+ UserMgr -> UserStore: FindByNameAsync(newUsername)
+ note right of UserMgr
+ Uniqueness check before the UPDATE.
+ end note
+ UserMgr -> UserStore: UpdateAsync(user)
+ UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
+ UserStore --> UserMgr: IdentityResult
+ UserMgr --> UpdateUsernameHandler: IdentityResult
+ deactivate UserMgr
+
+ alt !result.Succeeded
+ UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
+ Mediator -> API: propagate
+ API -> User: 409 Conflict
+ else result.Succeeded
+ UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
+ deactivate UpdateUsernameHandler
+ Mediator --> API: UpdateUsernamePayload
+ deactivate Mediator
+ API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
+ end
+ end
+end
+deactivate API
+
+@enduml
diff --git a/docs/website/diagrams-src/authentication-flow.puml b/docs/website/diagrams-src/authentication-flow.puml
deleted file mode 100644
index b5c97f47..00000000
--- a/docs/website/diagrams-src/authentication-flow.puml
+++ /dev/null
@@ -1,927 +0,0 @@
-@startuml authentication-flow
-!theme plain
-skinparam backgroundColor #FFFFFF
-skinparam defaultFontName Arial
-skinparam sequenceMessageAlign center
-skinparam maxMessageSize 200
-
-title User Authentication Flow - Expanded
-
-actor User
-participant "API\nController" as API
-participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
-box "Handler Layer" #LightBlue
-participant "RegisterUserHandler" as RegHandler
-participant "LoginHandler" as LoginHandler
-participant "RefreshTokenHandler" as RefreshHandler
-participant "ConfirmUserHandler" as ConfirmHandler
-participant "ResendConfirmationEmailHandler" as ResendHandler
-participant "UpdateUsernameHandler" as UpdateUsernameHandler
-participant "UpdateEmailHandler" as UpdateEmailHandler
-participant "UpdatePasswordHandler" as UpdatePasswordHandler
-participant "UpdateProfileHandler" as UpdateProfileHandler
-participant "DeleteAccountHandler" as DeleteAccountHandler
-participant "UserManager" as UserMgr
-participant "TokenService" as TokenSvc
-end box
-box "Infrastructure Layer" #LightGreen
-participant "Argon2PasswordHasher" as Argon2
-participant "JWT\nInfrastructure" as JWT
-participant "Email\nProvider" as SMTP
-participant "Template\nProvider" as Template
-end box
-box "Repository / Cross-slice" #LightYellow
-participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
-participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
-end box
-database "SQL Server" as DB
-
-note over UserMgr
- ASP.NET Core Identity's UserManager
- replaces the deleted AuthRepository: handlers
- no longer issue SQL or call Argon2 directly,
- they delegate to UserManager, which in turn
- calls DapperUserStore (persistence) and
- Argon2PasswordHasher (IPasswordHasher).
-end note
-
-== Registration Flow ==
-
-User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
-activate API
-
-API -> Mediator: Send(RegisterUserCommand)
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs RegisterUserValidator
- (FluentValidation) before the handler:
- - Username: 3-64 chars, alphanumeric + [._-]
- - Email: valid format, max 128 chars
- - Password: min 8 chars, uppercase,\n lowercase, number, special char
- - DateOfBirth: must be 19+ years old
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
-else Validation succeeds
- Mediator -> RegHandler: Handle(command)
- activate RegHandler
-
- RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
-
- RegHandler -> UserMgr: CreateAsync(user, password)
- activate UserMgr
-
- UserMgr -> Argon2: HashPassword(user, password)
- activate Argon2
- note right of Argon2
- Delegates to IPasswordInfrastructure.Hash:
- Argon2id parameters:
- - Salt: 16 bytes (128-bit)
- - Memory: 64MB
- - Iterations: 4
- - Parallelism: CPU count
- - Hash output: 32 bytes
- end note
- Argon2 --> UserMgr: "base64(salt):base64(hash)"
- deactivate Argon2
-
- UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
- note right of UserStore
- user.Id is still empty at this point -
- only sets the in-memory hash. CreateAsync
- writes it to dbo.UserCredential itself.
- end note
-
- UserMgr -> UserStore: FindByNameAsync(username)
- activate UserStore
- UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
- activate DB
- DB --> UserStore: null (user doesn't exist)
- deactivate DB
- deactivate UserStore
-
- UserMgr -> UserStore: FindByEmailAsync(email)
- activate UserStore
- UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
- activate DB
- DB --> UserStore: null (email doesn't exist)
- deactivate DB
- deactivate UserStore
- note right of UserMgr
- Default UserManager validators check
- uniqueness before CreateAsync is called;
- DapperUserStore.CreateAsync also guards
- against a genuine race via a caught
- duplicate-key SqlException (2601/2627).
- end note
-
- UserMgr -> UserStore: CreateAsync(user)
- activate UserStore
-
- UserStore -> DB: BEGIN TRANSACTION
- activate DB
- UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
- DB --> UserStore: UserAccountId (GUID)
- UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
- UserStore -> DB: COMMIT TRANSACTION
- deactivate DB
- note right of UserStore
- Both statements run via Dapper's
- ExecuteScalarAsync/ExecuteAsync against
- a single DbTransaction, not a stored
- procedure - no manual parameter binding
- end note
-
- UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
- deactivate UserStore
-
- UserMgr --> RegHandler: IdentityResult
- deactivate UserMgr
-
- alt !result.Succeeded
- RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
- Mediator -> API: propagate
- API -> User: 409 Conflict\n"Username or email already exists"
- else result.Succeeded
-
- RegHandler -> TokenSvc: GenerateAccessToken(user)
- activate TokenSvc
- TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
- activate JWT
- note right of JWT
- JWT Configuration:
- - Algorithm: HS256
- - Expires: 1 hour
- - Claims:
- * sub: userId
- * unique_name: username
- * iat / jti
- end note
- JWT -> JWT: Create JWT with claims
- JWT -> JWT: Sign with secret key
- JWT --> TokenSvc: Access Token
- deactivate JWT
- TokenSvc --> RegHandler: Access Token
- deactivate TokenSvc
-
- RegHandler -> TokenSvc: GenerateRefreshToken(user)
- activate TokenSvc
- TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
- activate JWT
- note right of JWT
- Refresh Token:
- - Expires: 21 days (504 hours)
- - Same structure as access token,\n signed with a separate secret
- end note
- JWT --> TokenSvc: Refresh Token
- deactivate JWT
- TokenSvc --> RegHandler: Refresh Token
- deactivate TokenSvc
-
- RegHandler -> TokenSvc: GenerateConfirmationToken(user)
- activate TokenSvc
- TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
- activate JWT
- note right of JWT
- Confirmation Token:
- - Expires: 30 minutes (0.5 hours)
- - Signed with a third, separate secret
- end note
- JWT --> TokenSvc: Confirmation Token
- deactivate JWT
- TokenSvc --> RegHandler: Confirmation Token
- deactivate TokenSvc
-
- RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
- activate Mediator
- Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
- note right of Mediator
- Routed to Features.Emails'
- SendRegistrationEmailHandler,
- the only cross-slice call in
- the backend
- end note
- activate EmailDispatcher
-
- EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
- activate Template
- note right of Template
- Razor Component:
- - Header with branding
- - Welcome message
- - Confirmation button
- - Footer
- end note
- Template -> Template: Render Razor component\nto HTML
- Template --> EmailDispatcher: HTML email content
- deactivate Template
-
- EmailDispatcher -> SMTP: SendAsync(email, subject, body)
- activate SMTP
- note right of SMTP
- SMTP Configuration (MailKit):
- - Host/port: from env (SMTP_HOST/SMTP_PORT)
- - TLS: StartTLS when SMTP_USE_SSL=true
- - Auth: username/password, if configured
- end note
- SMTP -> SMTP: Build MIME message
- SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
- SMTP --> EmailDispatcher: Success / throws
- deactivate SMTP
-
- deactivate EmailDispatcher
- deactivate Mediator
-
- alt Email sent successfully
- RegHandler -> RegHandler: emailSent = true
- else mediator.Send throws
- RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
- end
-
- RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
- deactivate RegHandler
- Mediator --> API: RegistrationPayload
- deactivate Mediator
-
- API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
- end
-end
-deactivate API
-
-== Login Flow ==
-
-User -> API: POST /api/auth/login\n{username, password}
-activate API
-
-API -> Mediator: Send(LoginCommand)
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs LoginValidator:
- username and password must both be non-empty
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
-else Validation succeeds
-
- Mediator -> LoginHandler: Handle(command)
- activate LoginHandler
-
- LoginHandler -> UserMgr: FindByNameAsync(username)
- activate UserMgr
- UserMgr -> UserStore: FindByNameAsync(username)
- activate UserStore
- UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
- activate DB
- DB --> UserStore: UserAccount row or null
- deactivate DB
- UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
- UserStore --> UserMgr: ApplicationUser or null
- deactivate UserStore
- UserMgr --> LoginHandler: ApplicationUser or null
- deactivate UserMgr
-
- alt User not found
- LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
- else User found
-
- LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
- activate UserMgr
- note right of UserMgr
- HasPasswordAsync short-circuits to false
- (no active UserCredential was hydrated)
- without calling the hasher; otherwise
- delegates to Argon2PasswordHasher.
- end note
- UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
- activate Argon2
- note right of Argon2
- Delegates to IPasswordInfrastructure.Verify:
- 1. Split stored hash: "salt:hash"
- 2. Hash provided password with the same salt
- 3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
- end note
- Argon2 --> UserMgr: Success/Failed
- deactivate Argon2
- UserMgr --> LoginHandler: true/false
- deactivate UserMgr
-
- alt Password invalid
- LoginHandler -> Mediator: throw UnauthorizedException
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
- else Password valid
-
- LoginHandler -> TokenSvc: GenerateAccessToken(user)
- activate TokenSvc
- TokenSvc -> JWT: GenerateJwt(...)
- activate JWT
- JWT --> TokenSvc: Access Token
- deactivate JWT
- TokenSvc --> LoginHandler: Access Token
- deactivate TokenSvc
-
- LoginHandler -> TokenSvc: GenerateRefreshToken(user)
- activate TokenSvc
- TokenSvc -> JWT: GenerateJwt(...)
- activate JWT
- JWT --> TokenSvc: Refresh Token
- deactivate JWT
- TokenSvc --> LoginHandler: Refresh Token
- deactivate TokenSvc
-
- LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
- deactivate LoginHandler
- Mediator --> API: LoginPayload
- deactivate Mediator
-
- API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
- end
- end
- end
-deactivate API
-
-== Refresh Token Flow ==
-
-User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token:
-activate API
-
-API -> Mediator: Send(RefreshTokenCommand(refreshToken))
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs RefreshTokenValidator:
- refreshToken must be non-empty. Anonymous
- endpoint - no Bearer token required.
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
-else Validation succeeds
-
- Mediator -> RefreshHandler: Handle(command)
- activate RefreshHandler
-
- RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken)
- activate TokenSvc
-
- TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken)
- activate JWT
- note right of JWT
- Validates signature/expiry against the
- refresh token secret, extracts userId/
- username claims.
- end note
- JWT --> TokenSvc: ValidatedToken or throws
- deactivate JWT
-
- alt Token invalid or expired
- TokenSvc -> RefreshHandler: throw UnauthorizedException
- RefreshHandler -> Mediator: propagate
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
- else Token valid
-
- TokenSvc -> UserMgr: FindByIdAsync(validated.UserId)
- activate UserMgr
- UserMgr -> UserStore: FindByIdAsync(userId)
- activate UserStore
- UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
- activate DB
- DB --> UserStore: row or null
- deactivate DB
- UserStore --> UserMgr: ApplicationUser or null
- deactivate UserStore
- UserMgr --> TokenSvc: ApplicationUser or null
- deactivate UserMgr
-
- alt User not found
- TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found"
- RefreshHandler -> Mediator: propagate
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
- else User found
- TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
- JWT --> TokenSvc: new access + refresh tokens
- TokenSvc --> RefreshHandler: RefreshTokenResult
- deactivate TokenSvc
-
- RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
- deactivate RefreshHandler
- Mediator --> API: LoginPayload
- deactivate Mediator
-
- API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
- end
- end
-end
-deactivate API
-
-== Confirm User Flow ==
-
-User -> API: POST /api/auth/confirm?token=\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(ConfirmUserCommand(token))
-activate Mediator
-
-Mediator -> ConfirmHandler: Handle(command)
-activate ConfirmHandler
-
-ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token)
-activate TokenSvc
-TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId
-activate JWT
-JWT --> TokenSvc: ValidatedToken or throws
-deactivate JWT
-TokenSvc --> ConfirmHandler: ValidatedToken
-deactivate TokenSvc
-
-alt Token invalid or expired
- ConfirmHandler -> Mediator: throw UnauthorizedException
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
-else Token valid
-
- ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId)
- activate UserMgr
- UserMgr -> UserStore: FindByIdAsync(userId)
- activate UserStore
- UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
- activate DB
- DB --> UserStore: row or null
- deactivate DB
- UserStore --> UserMgr: ApplicationUser or null
- deactivate UserStore
- UserMgr --> ConfirmHandler: ApplicationUser or null
- deactivate UserMgr
-
- alt User not found
- ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found"
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
- else User found
-
- ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true)
- note right of UserStore
- IUserEmailStore is injected
- directly into the handler (not via UserManager),
- setting the confirmed flag in-memory here.
- end note
-
- ConfirmHandler -> UserMgr: UpdateAsync(user)
- activate UserMgr
- UserMgr -> UserStore: UpdateAsync(user)
- activate UserStore
- UserStore -> DB: UPDATE dbo.UserAccount SET ...
- UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
- note right of UserStore
- Idempotent: skipped (duplicate-key
- exception swallowed) if a verification
- row already exists.
- end note
- UserStore --> UserMgr: IdentityResult.Success
- deactivate UserStore
- UserMgr --> ConfirmHandler: IdentityResult
- deactivate UserMgr
-
- ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
- deactivate ConfirmHandler
- Mediator --> API: ConfirmationPayload
- deactivate Mediator
-
- API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
- end
-end
-deactivate API
-
-== Resend Confirmation Email Flow ==
-
-User -> API: POST /api/auth/confirm/resend?userId=\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(ResendConfirmationEmailCommand(userId))
-activate Mediator
-
-Mediator -> ResendHandler: Handle(command)
-activate ResendHandler
-
-ResendHandler -> UserMgr: FindByIdAsync(userId)
-activate UserMgr
-UserMgr -> UserStore: FindByIdAsync(userId)
-activate UserStore
-UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
-activate DB
-DB --> UserStore: row or null
-deactivate DB
-UserStore --> UserMgr: ApplicationUser or null
-deactivate UserStore
-UserMgr --> ResendHandler: ApplicationUser or null
-deactivate UserMgr
-
-alt User not found
- ResendHandler -> ResendHandler: return silently\n(prevents user enumeration)
-else User found
-
- ResendHandler -> UserMgr: IsEmailConfirmedAsync(user)
- UserMgr --> ResendHandler: bool
-
- alt Already confirmed
- ResendHandler -> ResendHandler: return silently (no-op)
- else Not yet confirmed
-
- ResendHandler -> TokenSvc: GenerateConfirmationToken(user)
- activate TokenSvc
- TokenSvc --> ResendHandler: confirmation token
- deactivate TokenSvc
-
- ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand)
- activate Mediator
- Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken)
- activate EmailDispatcher
- EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration)
- EmailDispatcher -> SMTP: SendAsync(email, subject, body)
- deactivate EmailDispatcher
- deactivate Mediator
- end
-end
-
-ResendHandler --> Mediator: (void)
-deactivate ResendHandler
-Mediator --> API: (void)
-deactivate Mediator
-
-API -> User: 200 OK\n{message: "confirmation email has been resent"}
-note right of API
- Always 200 OK regardless of outcome above -
- same user-enumeration precaution as a not-found
- user or an already-confirmed account.
-end note
-deactivate API
-
-== Update Username Flow ==
-
-User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs UpdateUsernameValidator
- (same format rules as registration)
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request
-else Validation succeeds
-
- Mediator -> UpdateUsernameHandler: Handle(command)
- activate UpdateUsernameHandler
-
- UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId)
- activate UserMgr
- UserMgr -> UserStore: FindByIdAsync(userAccountId)
- activate UserStore
- UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
- activate DB
- DB --> UserStore: row or null
- deactivate DB
- UserStore --> UserMgr: ApplicationUser or null
- deactivate UserStore
- UserMgr --> UpdateUsernameHandler: ApplicationUser or null
- deactivate UserMgr
-
- alt Account not found
- UpdateUsernameHandler -> Mediator: throw NotFoundException
- Mediator -> API: propagate
- API -> User: 404 Not Found
- else Account found
-
- UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername)
- activate UserMgr
- UserMgr -> UserStore: FindByNameAsync(newUsername)
- note right of UserMgr
- Uniqueness check before the UPDATE.
- end note
- UserMgr -> UserStore: UpdateAsync(user)
- UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
- UserStore --> UserMgr: IdentityResult
- UserMgr --> UpdateUsernameHandler: IdentityResult
- deactivate UserMgr
-
- alt !result.Succeeded
- UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
- Mediator -> API: propagate
- API -> User: 409 Conflict
- else result.Succeeded
- UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
- deactivate UpdateUsernameHandler
- Mediator --> API: UpdateUsernamePayload
- deactivate Mediator
- API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
- end
- end
-end
-deactivate API
-
-== Update Email Flow ==
-
-User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs UpdateEmailValidator
- (same format rules as registration)
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request
-else Validation succeeds
-
- Mediator -> UpdateEmailHandler: Handle(command)
- activate UpdateEmailHandler
-
- UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId)
- activate UserMgr
- UserMgr -> UserStore: FindByIdAsync(userAccountId)
- UserStore -> DB: SELECT ...
- UserStore --> UserMgr: ApplicationUser or null
- UserMgr --> UpdateEmailHandler: ApplicationUser or null
- deactivate UserMgr
-
- alt Account not found
- UpdateEmailHandler -> Mediator: throw NotFoundException
- Mediator -> API: propagate
- API -> User: 404 Not Found
- else Account found
-
- UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail)
- activate UserMgr
- UserMgr -> UserStore: FindByEmailAsync(newEmail)
- note right of UserMgr
- Uniqueness check. Also resets
- user.EmailConfirmed = false -
- the caller must re-confirm via
- POST /api/auth/confirm/resend.
- end note
- UserMgr -> UserStore: UpdateAsync(user)
- UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
- UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
- UserStore --> UserMgr: IdentityResult
- UserMgr --> UpdateEmailHandler: IdentityResult
- deactivate UserMgr
-
- alt !result.Succeeded
- UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
- Mediator -> API: propagate
- API -> User: 409 Conflict
- else result.Succeeded
- UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
- deactivate UpdateEmailHandler
- Mediator --> API: UpdateEmailPayload
- deactivate Mediator
- API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
- end
- end
-end
-deactivate API
-
-== Update Password Flow ==
-
-User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs UpdatePasswordValidator
- (same password strength rules as registration)
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request
-else Validation succeeds
-
- Mediator -> UpdatePasswordHandler: Handle(command)
- activate UpdatePasswordHandler
-
- UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId)
- activate UserMgr
- UserMgr -> UserStore: FindByIdAsync(userAccountId)
- UserStore --> UserMgr: ApplicationUser or null
- UserMgr --> UpdatePasswordHandler: ApplicationUser or null
- deactivate UserMgr
-
- alt Account not found
- UpdatePasswordHandler -> Mediator: throw NotFoundException
- Mediator -> API: propagate
- API -> User: 404 Not Found
- else Account found
-
- UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword)
- activate UserMgr
- UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
- activate Argon2
- Argon2 --> UserMgr: Success/Failed
- deactivate Argon2
-
- alt Current password mismatch
- UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch)
- else Current password matches
- UserMgr -> Argon2: HashPassword(user, newPassword)
- activate Argon2
- Argon2 --> UserMgr: new hash
- deactivate Argon2
- UserMgr -> UserStore: SetPasswordHashAsync(user, newHash)
- activate UserStore
- UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
- UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
- note right of UserStore
- Revoke-then-insert runs inside one
- DbTransaction (credential rotation).
- end note
- UserStore --> UserMgr: (void)
- deactivate UserStore
- UserMgr --> UpdatePasswordHandler: IdentityResult.Success
- end
- deactivate UserMgr
-
- alt Current password is incorrect
- UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect"
- Mediator -> API: propagate
- API -> User: 401 Unauthorized
- else !result.Succeeded (other failure)
- UpdatePasswordHandler -> Mediator: throw ConflictException
- Mediator -> API: propagate
- API -> User: 409 Conflict
- else result.Succeeded
- UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow)
- deactivate UpdatePasswordHandler
- Mediator --> API: UpdatePasswordPayload
- deactivate Mediator
- API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
- end
- end
-end
-deactivate API
-
-== Update Profile Flow ==
-
-User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
-activate Mediator
-
-note right of Mediator
- ValidationBehavior runs UpdateProfileValidator
- (same name/DateOfBirth rules as registration)
-end note
-
-alt Validation fails
- Mediator -> API: throws FluentValidation.ValidationException
- API -> User: 400 Bad Request
-else Validation succeeds
-
- Mediator -> UpdateProfileHandler: Handle(command)
- activate UpdateProfileHandler
-
- UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId)
- activate UserMgr
- UserMgr -> UserStore: FindByIdAsync(userAccountId)
- UserStore --> UserMgr: ApplicationUser or null
- UserMgr --> UpdateProfileHandler: ApplicationUser or null
- deactivate UserMgr
-
- alt Account not found
- UpdateProfileHandler -> Mediator: throw NotFoundException
- Mediator -> API: propagate
- API -> User: 404 Not Found
- else Account found
-
- UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ...
- note right of UpdateProfileHandler
- First/last name and date of birth aren't
- tracked by any Identity store interface -
- mutated directly on the ApplicationUser,
- then saved via UserManager.UpdateAsync.
- end note
-
- UpdateProfileHandler -> UserMgr: UpdateAsync(user)
- activate UserMgr
- UserMgr -> UserStore: UpdateAsync(user)
- UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ...
- UserStore --> UserMgr: IdentityResult
- UserMgr --> UpdateProfileHandler: IdentityResult
- deactivate UserMgr
-
- alt !result.Succeeded
- UpdateProfileHandler -> Mediator: throw ConflictException
- Mediator -> API: propagate
- API -> User: 409 Conflict
- else result.Succeeded
- UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth)
- deactivate UpdateProfileHandler
- Mediator --> API: UpdateProfilePayload
- deactivate Mediator
- API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
- end
- end
-end
-deactivate API
-
-== Delete Account Flow ==
-
-User -> API: DELETE /api/auth/account\n(requires Bearer access token)
-activate API
-
-API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId()))
-activate Mediator
-
-Mediator -> DeleteAccountHandler: Handle(command)
-activate DeleteAccountHandler
-
-DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId)
-activate UserMgr
-UserMgr -> UserStore: FindByIdAsync(userAccountId)
-UserStore --> UserMgr: ApplicationUser or null
-UserMgr --> DeleteAccountHandler: ApplicationUser or null
-deactivate UserMgr
-
-alt Account not found
- DeleteAccountHandler -> Mediator: throw NotFoundException
- Mediator -> API: propagate
- API -> User: 404 Not Found
-else Account found
-
- DeleteAccountHandler -> UserMgr: DeleteAsync(user)
- activate UserMgr
- UserMgr -> UserStore: DeleteAsync(user)
- activate UserStore
- UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id
- note right of UserStore
- UserCredential/UserVerification/UserAvatar
- cascade on delete. Posts, comments, photos,
- and follows use ON DELETE NO ACTION - if any
- reference this account, the DELETE throws a
- foreign key violation (SQL error 547).
- end note
-
- alt Foreign key violation (dependent records exist)
- UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords)
- else Delete succeeds
- UserStore --> UserMgr: IdentityResult.Success
- end
- deactivate UserStore
- UserMgr --> DeleteAccountHandler: IdentityResult
- deactivate UserMgr
-
- alt Account has dependent records
- DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows."
- Mediator -> API: propagate
- API -> User: 409 Conflict
- else !result.Succeeded (other failure)
- DeleteAccountHandler -> Mediator: throw ConflictException
- Mediator -> API: propagate
- API -> User: 409 Conflict
- else result.Succeeded
- DeleteAccountHandler --> Mediator: (void)
- deactivate DeleteAccountHandler
- Mediator --> API: (void)
- deactivate Mediator
- API -> User: 200 OK\n{message: "Account deleted successfully."}
- end
-end
-deactivate API
-
-== Error Handling (Global Exception Filter) ==
-
-note over API
- API.Core's GlobalExceptionFilter catches:
- - FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
- - ConflictException -> 409 Conflict
- - NotFoundException -> 404 Not Found
- - UnauthorizedException -> 401 Unauthorized
- - ForbiddenException -> 403 Forbidden
- - Domain.Exceptions.ValidationException -> 400 Bad Request
- - Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
- - All others -> 500 Internal Server Error
-end note
-
-@enduml
diff --git a/docs/website/diagrams-src/database-schema.puml b/docs/website/diagrams-src/database-schema.puml
index 37a1269a..31777a12 100644
--- a/docs/website/diagrams-src/database-schema.puml
+++ b/docs/website/diagrams-src/database-schema.puml
@@ -1,9 +1,6 @@
@startuml database-schema
-!theme plain
-skinparam backgroundColor #FFFFFF
-skinparam defaultFontName Arial
+!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam linetype ortho
-skinparam packageStyle rectangle
title Database Schema
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
diff --git a/docs/website/diagrams-src/deployment.puml b/docs/website/diagrams-src/deployment.puml
index cdaa2e64..a212554f 100644
--- a/docs/website/diagrams-src/deployment.puml
+++ b/docs/website/diagrams-src/deployment.puml
@@ -1,7 +1,5 @@
@startuml deployment
-!theme plain
-skinparam backgroundColor #FFFFFF
-skinparam defaultFontName Arial
+!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
skinparam linetype ortho
title Docker Deployment Architecture
@@ -10,7 +8,7 @@ title Docker Deployment Architecture
actor Developer
cloud "Docker Host" as Host
-package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
+package "Development Environment\n(docker-compose.dev.yaml)" #F1F3EA {
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
database "Biergarten\nDatabase" as DevDBInner {
@@ -80,7 +78,7 @@ package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
}
-package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
+package "Test Environment\n(docker-compose.test.yaml)" #EBECE3 {
node "SQL Server\n(isolated instance)" as TestDB {
database "Biergarten\nTest Database" as TestDBInner {
@@ -142,7 +140,7 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
}
-package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC {
+package "Production Environment\n(docker-compose.prod.yaml)" #CBD2B5 {
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB {
database "Biergarten\nDatabase" as ProdDBInner
@@ -195,7 +193,7 @@ package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC {
end note
}
-package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 {
+package "Database-only Environment\n(docker-compose.db.yaml)" #E4E6D8 {
node "SQL Server" as DbOnlyDB {
database "Biergarten\nDatabase" as DbOnlyDBInner {
@@ -225,7 +223,7 @@ package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 {
DbOnlySeed .up.> DbOnlyMig : depends_on
}
-package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 {
+package "Minimal Environment\n(docker-compose.min.yaml)" #DBEEDD {
node "SQL Server" as MinDB {
database "Biergarten\nDatabase" as MinDBInner {