diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..edbb2ddc
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,18 @@
+{
+ "image": "mcr.microsoft.com/devcontainers/dotnet",
+ "features": {
+ "ghcr.io/devcontainers/features/dotnet:2": {
+ "version": "9.0"
+ }
+ },
+ "postCreateCommand": ".devcontainer/setup-nuget-auth.sh",
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-dotnettools.csharp",
+ "ms-dotnettools.csdevkit"
+ ]
+ }
+ },
+ "remoteUser": "vscode"
+}
diff --git a/.devcontainer/setup-nuget-auth.sh b/.devcontainer/setup-nuget-auth.sh
new file mode 100755
index 00000000..1fbd62df
--- /dev/null
+++ b/.devcontainer/setup-nuget-auth.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+set -e
+
+echo "Setting up NuGet authentication for Azure DevOps..."
+
+# Install Azure Artifacts Credential Provider
+echo "Installing Azure Artifacts Credential Provider..."
+if sh -c "$(curl -fsSL https://aka.ms/install-artifacts-credprovider.sh)" 2>/dev/null; then
+ echo "β Azure Artifacts Credential Provider installed successfully"
+else
+ echo "β οΈ Could not download Azure Artifacts Credential Provider installer."
+ echo "This may be due to network restrictions. Falling back to public packages only."
+ export ACCESS_TO_NUGET_FEED=false
+fi
+
+# Display .NET version
+echo "Checking .NET SDK version..."
+dotnet --version
+
+# Try to restore packages with interactive authentication if credential provider is available
+echo "Attempting to restore NuGet packages..."
+if command -v dotnet-credential-provider-installer >/dev/null 2>&1 || [ -n "$(find ~/.nuget -name "*CredentialProvider*" 2>/dev/null | head -1)" ]; then
+ echo ""
+ echo "π The credential provider is available for Azure DevOps authentication."
+ echo "If prompted for credentials during package restoration, you can:"
+ echo " 1. Use your Azure DevOps account credentials, or"
+ echo " 2. Create a Personal Access Token (PAT) with 'Packaging (read)' permissions"
+ echo " from: https://dev.azure.com/intelliTect/_usersSettings/tokens"
+ echo ""
+
+ # First try to restore with interactive authentication for private packages
+ if dotnet restore --interactive -p:AccessToNugetFeed=true; then
+ echo "β Package restoration successful with private feed access!"
+ else
+ echo "β οΈ Private package restoration failed or was cancelled."
+ echo "Falling back to public packages only..."
+ if dotnet restore -p:AccessToNugetFeed=false; then
+ echo "β Package restoration successful with public packages only!"
+ else
+ echo "β Package restoration failed completely."
+ exit 1
+ fi
+ fi
+else
+ echo "Credential provider not available, using public packages only..."
+ if dotnet restore -p:AccessToNugetFeed=false; then
+ echo "β Package restoration successful with public packages only!"
+ else
+ echo "β Package restoration failed."
+ exit 1
+ fi
+fi
+
+echo "π Devcontainer setup complete!"
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..3729ff0c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index bed05628..8b6b66f0 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -63,6 +63,8 @@ dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
end_of_line = crlf
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_allow_multiple_blank_lines_experimental = false:suggestion
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
###############################
# C# Coding Conventions #
###############################
@@ -123,9 +125,9 @@ csharp_preserve_single_line_blocks = true
# IntelliTect Conventions #
###############################
# var preferences
-csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_for_built_in_types = false:none
csharp_style_var_when_type_is_apparent = true:suggestion
-csharp_style_var_elsewhere = false:warning
+csharp_style_var_elsewhere = false:none
## Naming
# Style Definitions
@@ -151,4 +153,7 @@ csharp_style_namespace_declarations = block_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_expression_bodied_lambdas = true:silent
-csharp_style_expression_bodied_local_functions = false:silent
\ No newline at end of file
+csharp_style_expression_bodied_local_functions = false:silent
+
+# CA1848: Use the LoggerMessage delegates
+dotnet_diagnostic.CA1848.severity = suggestion
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..b49228d9
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,265 @@
+# GitHub Copilot Instructions for EssentialCSharp.Web
+
+## Project Overview & Core Purpose
+This is a comprehensive ASP.NET Core 9.0 web application ecosystem for the **Essential C#** programming education platform. The project serves as the technical foundation for [essentialcsharp.com](https://essentialcsharp.com/), providing educational content, AI-powered chat assistance, and user management for C# learning resources.
+
+**Key Value**: Provides an interactive learning platform where developers can access Essential C# content, engage with AI-powered assistance for C# questions, and track their learning progress through a modern web interface.
+
+**Target Audience**: C# developers at all levels, students learning C#, and educators teaching C# programming concepts.
+
+## Project Structure & Architecture
+This solution follows a modular architecture with clear separation of concerns:
+
+```
+EssentialCSharp.Web.sln
+βββ EssentialCSharp.Web/ # Main ASP.NET Core web application
+β βββ Areas/ # Identity and feature-specific areas
+β βββ Controllers/ # MVC controllers
+β βββ Views/ # Razor views and layouts
+β βββ Models/ # View models and data models
+β βββ Services/ # Business logic services
+β βββ Migrations/ # Entity Framework migrations
+β βββ wwwroot/ # Static assets (CSS, JS, images)
+βββ EssentialCSharp.Chat/ # Console application for AI chat
+βββ EssentialCSharp.Chat.Shared/ # Shared models and services
+βββ EssentialCSharp.Web.Tests/ # Integration tests for web app
+βββ EssentialCSharp.Chat.Tests/ # Unit tests for chat functionality
+```
+
+## Tech Stack & Core Technologies
+
+### Framework & Runtime
+- **.NET 9.0** with **C# 13** language features
+- **ASP.NET Core 9.0** for web application framework
+- **Entity Framework Core 8.0.10** for data access with SQL Server
+- **ASP.NET Core Identity** for user authentication and authorization
+
+### AI & Chat Integration
+- **Microsoft Semantic Kernel 1.60.0** for AI orchestration
+- **Azure OpenAI** integration for chat functionality
+- **pgvector with PostgreSQL** for vector database operations
+- **Model Context Protocol (MCP)** for AI agent integration
+
+### Authentication & Security
+- **GitHub OAuth** integration for developer authentication
+- **Microsoft Account** authentication support
+- **HCaptcha** for bot protection
+- **JWT tokens** for API authentication
+
+### Development & Deployment
+- **Docker** containerization with multi-stage builds
+- **Azure Application Insights** for telemetry and monitoring
+- **Azure Monitor OpenTelemetry** for observability
+- **Mailjet API** for email services
+
+### Package Management & Build
+- **Central Package Management** via `Directory.Packages.props`
+- **Private NuGet feed** from Azure DevOps for internal packages
+- **Source Link** for debugging support
+- **Build versioning** with continuous integration support
+
+## Coding Conventions & Development Patterns
+
+### Project Structure Conventions
+- **Areas**: Use for Identity and distinct feature modules
+- **Controllers**: Follow MVC pattern with async actions
+- **Services**: Implement business logic with dependency injection
+- **Models**: Separate view models from domain models
+- **Extensions**: Place extension methods in dedicated Extension classes
+
+### Code Style & Patterns
+- **Async/Await**: Use consistently for all I/O operations
+- **Dependency Injection**: Register services in `Program.cs`
+- **Repository Pattern**: Implement for data access abstraction
+- **Error Handling**: Use structured logging and custom exceptions
+- **Configuration**: Use strongly-typed configuration classes
+
+### File Naming & Organization
+- **Controllers**: `{Feature}Controller.cs` (e.g., `HomeController.cs`)
+- **Services**: `{Feature}Service.cs` and `I{Feature}Service.cs`
+- **Models**: `{Entity}Model.cs` for view models, `{Entity}.cs` for domain
+- **Extensions**: `{Type}Extensions.cs`
+- **Tests**: `{ClassUnderTest}Tests.cs`
+
+## Testing Strategy & Frameworks
+
+### Test Organization
+- **Integration Tests**: `EssentialCSharp.Web.Tests` using `Microsoft.AspNetCore.Mvc.Testing`
+- **Unit Tests**: `EssentialCSharp.Chat.Tests` using `xUnit` and `Moq`
+- **Test Structure**: Follow AAA pattern (Arrange, Act, Assert)
+
+### Testing Tools
+- **xUnit 2.9.3** as the primary testing framework
+- **Moq 4.20.72** for mocking dependencies
+- **Coverlet** for code coverage collection
+- **Microsoft.AspNetCore.Mvc.Testing** for integration testing
+
+### Test Conventions
+```csharp
+[Fact]
+public async Task MethodName_Scenario_ExpectedBehavior()
+{
+ // Arrange
+ var service = new TestService();
+
+ // Act
+ var result = await service.MethodAsync();
+
+ // Assert
+ Assert.NotNull(result);
+}
+```
+
+## Build & Development Commands
+
+### Essential Commands
+```bash
+# Restore all packages
+dotnet restore
+
+# Build entire solution
+dotnet build --configuration Release --no-restore
+
+# Run all tests
+dotnet test --no-build --configuration Release
+
+# Run web application
+dotnet run --project EssentialCSharp.Web
+
+# Run chat application
+dotnet run --project EssentialCSharp.Chat
+
+# Entity Framework operations
+dotnet ef migrations add MigrationName --project EssentialCSharp.Web
+dotnet ef database update --project EssentialCSharp.Web
+```
+
+### Docker Operations
+```bash
+# Build Docker image
+docker build -t essentialcsharp-web -f EssentialCSharp.Web/Dockerfile .
+
+# Run with Docker Compose (if available)
+docker-compose up --build
+```
+
+## Configuration & Environment Setup
+
+### Required Secrets (Use dotnet user-secrets)
+```bash
+# Email configuration
+dotnet user-secrets set "AuthMessageSender:SendFromName" "Essential C# Team"
+dotnet user-secrets set "AuthMessageSender:SendFromEmail" "no-reply@essentialcsharp.com"
+dotnet user-secrets set "AuthMessageSender:SecretKey" "your-mailjet-secret"
+dotnet user-secrets set "AuthMessageSender:APIKey" "your-mailjet-api-key"
+
+# OAuth providers
+dotnet user-secrets set "Authentication:Microsoft:ClientSecret" "microsoft-oauth-secret"
+dotnet user-secrets set "Authentication:Microsoft:ClientId" "microsoft-oauth-client-id"
+dotnet user-secrets set "Authentication:github:clientSecret" "github-oauth-secret"
+dotnet user-secrets set "Authentication:github:clientId" "github-oauth-client-id"
+
+# Security
+dotnet user-secrets set "HCaptcha:SiteKey" "hcaptcha-site-key"
+dotnet user-secrets set "HCaptcha:SecretKey" "hcaptcha-secret-key"
+
+# Application Insights
+dotnet user-secrets set "APPLICATIONINSIGHTS_CONNECTION_STRING" "your-app-insights-connection"
+```
+
+### Package Feed Configuration
+- Set `false` in `Directory.Packages.props` if you don't have access to private Azure DevOps feed
+- Private feed contains `EssentialCSharp.Shared.Models` and content packages
+
+## AI Integration Patterns
+
+### Semantic Kernel Usage
+- **Chat Services**: Implement AI chat functionality using Semantic Kernel
+- **Vector Operations**: Use pgvector for semantic search and retrieval
+- **Model Context Protocol**: Integrate with MCP for agent communication
+- **Prompt Engineering**: Store prompts as structured templates
+
+### AI Service Patterns
+```csharp
+public interface IChatService
+{
+ Task ProcessMessageAsync(string message, CancellationToken cancellationToken);
+ Task> SearchContentAsync(string query, CancellationToken cancellationToken);
+}
+```
+
+## Development Workflow & Best Practices
+
+### Code Review Guidelines
+- Ensure all new code includes appropriate tests
+- Follow established naming conventions and patterns
+- Use async/await for all I/O operations
+- Implement proper error handling and logging
+- Add XML documentation for public APIs
+
+### Performance Considerations
+- Use Entity Framework efficiently (avoid N+1 queries)
+- Implement caching where appropriate
+- Use async patterns for database and API calls
+- Optimize Docker image size with multi-stage builds
+
+### Security Best Practices
+- Never commit secrets to source control
+- Use HTTPS for all external communications
+- Implement proper input validation
+- Follow OWASP security guidelines
+- Use parameterized queries for database operations
+
+## Common Patterns & Utilities
+
+### Service Registration Pattern
+```csharp
+// In Program.cs
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+```
+
+### Configuration Pattern
+```csharp
+public class FeatureOptions
+{
+ public const string SectionName = "Feature";
+ public string ApiKey { get; set; } = string.Empty;
+ public int TimeoutSeconds { get; set; } = 30;
+}
+
+// Registration
+builder.Services.Configure(
+ builder.Configuration.GetSection(FeatureOptions.SectionName));
+```
+
+### Error Handling Pattern
+```csharp
+public async Task> MethodAsync()
+{
+ try
+ {
+ var result = await SomeOperationAsync();
+ return Result.Success(result);
+ }
+ catch (SpecificException ex)
+ {
+ logger.LogError(ex, "Operation failed");
+ return Result.Failure(ex.Message);
+ }
+}
+```
+
+## Future Roadmap Considerations
+- **Microservices Evolution**: Consider splitting into microservices as features grow
+- **Performance Optimization**: Implement advanced caching and CDN strategies
+- **AI Enhancement**: Expand AI capabilities with more sophisticated models
+- **Mobile Support**: Potential mobile app integration
+- **API Expansion**: RESTful API for third-party integrations
+
+## Anti-Patterns & Gotchas
+- **Avoid**: Synchronous calls in async methods (use ConfigureAwait(false))
+- **Avoid**: Large Entity Framework queries without pagination
+- **Avoid**: Hardcoded configuration values (use appsettings.json)
+- **Avoid**: Missing error handling in async operations
+- **Security**: Never expose sensitive configuration in client-side code
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index ff37dc42..af927156 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,17 +5,53 @@ registries:
url: https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json
username: ${{secrets.AZURE_DEVOPS_PAT_USERNAME}}
password: ${{secrets.AZURE_DEVOPS_PAT}}
+multi-ecosystem-groups:
+ dotnet-sdk-updates:
+ schedule:
+ interval: "weekly"
updates:
- package-ecosystem: "nuget" # See documentation for possible values
- directory: "/" # Location of package manifests
+ directories:
+ - "/"
registries:
- nuget-azure-artifacts
schedule:
interval: "daily"
- time: "03:00"
+ time: "04:00"
timezone: "America/Los_Angeles"
+ groups:
+ tooling-dependencies:
+ applies-to: version-updates
+ patterns:
+ - "ContentFeedNuget"
+ - "EssentialCSharp.Shared.Models"
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+
+ - package-ecosystem: "docker"
+ # Look for a `Dockerfile` in the `root` directory
+ directory: "/EssentialCSharp.Web/"
+ patterns:
+ - "*"
+ multi-ecosystem-group: "dotnet-sdk-updates"
+ # Check for updates once a week
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "dotnet-sdk"
+ # Look for a `global.json` in the `root` directory
+ directory: "/"
+ patterns:
+ - "*"
+ multi-ecosystem-group: "dotnet-sdk-updates"
+ # Check for updates once a week
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "devcontainers"
+ directory: "/"
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/Automerge-ContentNugetFeed.yml b/.github/workflows/Automerge-ContentNugetFeed.yml
new file mode 100644
index 00000000..296a86d8
--- /dev/null
+++ b/.github/workflows/Automerge-ContentNugetFeed.yml
@@ -0,0 +1,33 @@
+name: Dependabot auto-merge
+on: pull_request
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ dependabot:
+ runs-on: ubuntu-latest
+ if: ${{ github.actor == 'dependabot[bot]' }}
+ steps:
+ - name: Dependabot metadata
+ id: metadata
+ uses: dependabot/fetch-metadata@v2
+ with:
+ github-token: ${{ secrets.BOT_PAT }}
+ - name: Add a label for content updates
+ if: ${{contains(steps.metadata.outputs.dependency-names, 'ContentFeedNuget')}}
+ run: gh pr edit "$PR_URL" --add-label "content update"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{ secrets.BOT_PAT }}
+ - name: Approve a PR
+ run: gh pr review --approve "$PR_URL"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.BOT_PAT}}
+ - name: Enable auto-merge for Dependabot PRs
+ run: gh pr merge --auto --squash --delete-branch "$PR_URL"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.BOT_PAT}}
diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml
index 498d7dd7..62f3035b 100644
--- a/.github/workflows/Build-Test-And-Deploy.yml
+++ b/.github/workflows/Build-Test-And-Deploy.yml
@@ -2,29 +2,31 @@ name: Build, Test, and Deploy EssentialCSharp.Web
on:
push:
- branches: [ "main" ]
- pull_request:
- schedule:
- - cron: '0 4 * * *'
+ branches: ["main"]
workflow_dispatch:
+permissions:
+ id-token: write
+ contents: read
+
jobs:
build-and-test:
runs-on: ubuntu-latest
+ environment: "BuildAndUploadImage"
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v6
- name: Set up .NET Core
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v5
with:
global-json-file: global.json
source-url: https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json
env:
- NUGET_AUTH_TOKEN: ${{secrets.AZURE_DEVOPS_PAT}}
+ NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }}
- name: Set up dependency caching for faster builds
- uses: actions/cache@v3
+ uses: actions/cache@v5
id: nuget-cache
with:
path: |
@@ -34,69 +36,227 @@ jobs:
restore-keys: |
${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
${{ runner.os }}-nuget-
-
+
- name: Restore with dotnet
run: dotnet restore
-
+
- name: Build with dotnet
- run: dotnet build --configuration Release --no-restore
-
+ run: dotnet build -p:ContinuousIntegrationBuild=True -p:ReleaseDateAttribute=True --configuration Release --no-restore
+
- name: Run .NET Tests
run: dotnet test --no-build --configuration Release
- - name: dotnet publish
- if: github.event_name != 'pull_request'
- run: dotnet publish -c Release -p:PublishDir=${{github.workspace}}/DeployEssentialCSharp.Web
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
- - name: Upload artifact for deployment job
- if: github.event_name != 'pull_request'
- uses: actions/upload-artifact@v3
+# Build but no push with a PR
+ - name: Docker build (no push)
+ if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
+ uses: docker/build-push-action@v6
with:
- name: .net-app
- path: ${{github.workspace}}/DeployEssentialCSharp.Web
+ push: false
+ tags: temp-pr-validation
+ file: ./EssentialCSharp.Web/Dockerfile
+
+ - name: Build Container Image
+ if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
+ uses: docker/build-push-action@v6
+ with:
+ tags: ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb:latest,${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb:${{ github.sha }},${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb:latest
+ file: ./EssentialCSharp.Web/Dockerfile
+ context: .
+ secrets: |
+ "nuget_auth_token=${{ secrets.AZURE_DEVOPS_PAT }}"
+ outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ - name: Upload artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: essentialcsharpwebimage
+ path: ${{ github.workspace }}/essentialcsharpwebimage.tar
deploy-development:
- if: github.event_name != 'pull_request'
+ if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
needs: build-and-test
environment:
- name: 'Development'
- url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ name: "Development"
steps:
- - name: Download artifact from build job
- uses: actions/download-artifact@v3
+ - name: Azure Login
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.ESSENTIALCSHARPDEV_CLIENT_ID }}
+ tenant-id: ${{ secrets.ESSENTIALCSHARP_APPIDENTITY_TENANT_ID }}
+ subscription-id: ${{ secrets.ESSENTIALCSHARP_SUBSCRIPTION_ID }}
+
+ - name: Download artifact
+ uses: actions/download-artifact@v7
with:
- name: .net-app
+ name: essentialcsharpwebimage
+ path: ${{ github.workspace }}
- - name: Deploy to Azure Web App
- id: deploy-to-webapp
- uses: azure/webapps-deploy@v2
+ - name: Load image
+ run: |
+ docker load --input ${{ github.workspace }}/essentialcsharpwebimage.tar
+ docker image ls -a
+
+ - name: Log in to container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ vars.DEVCONTAINER_REGISTRY }}
+ username: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
+ password: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
+
+ - name: Push Image to Container Registry
+ run: docker push --all-tags ${{ vars.DEVCONTAINER_REGISTRY }}/essentialcsharpweb
+
+ - name: Create and Deploy to Container App
+ uses: azure/CLI@v2
+ env:
+ CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
+ RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
+ CONTAINER_REGISTRY: ${{ vars.DEVCONTAINER_REGISTRY }}
+ CONTAINER_APP_ENVIRONMENT: ${{ vars.CONTAINER_APP_ENVIRONMENT }}
+ ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
+ ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
+ with:
+ inlineScript: |
+ az config set extension.use_dynamic_install=yes_without_prompt
+ az containerapp up -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --image $CONTAINER_REGISTRY/essentialcsharpweb:${{ github.sha }} --environment $CONTAINER_APP_ENVIRONMENT --registry-server $CONTAINER_REGISTRY --ingress external --target-port 8080 --registry-username $ACR_USERNAME --registry-password $ACR_PASSWORD
+
+ - name: Assign Managed Identity to Container App and Set Secrets and Environment Variables
+ uses: azure/CLI@v2
+ env:
+ CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
+ RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
+ CONTAINER_REGISTRY: ${{ vars.DEVCONTAINER_REGISTRY }}
+ CONTAINER_APP_ENVIRONMENT: ${{ vars.CONTAINER_APP_ENVIRONMENT }}
+ KEYVAULTURI: ${{ secrets.ESSENTIALCSHARP_KEYVAULT_URI }}
+ MANAGEDIDENTITYID: ${{ secrets.ESSENTIALCSHARP_APPIDENTITY_ID }}
+ ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
+ ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
+ AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }}
with:
- app-name: EssentialCSharpDev
- slot-name: 'Production'
- publish-profile: ${{ secrets.AZURE_DEVELOPMENT_PUBLISH_PROFILE }}
- package: .
-
+ inlineScript: |
+ az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }}
+ az containerapp secret set -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --secrets github-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientid,identityref:$MANAGEDIDENTITYID \
+ github-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientsecret,identityref:$MANAGEDIDENTITYID msft-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientid,identityref:$MANAGEDIDENTITYID \
+ msft-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientsecret,identityref:$MANAGEDIDENTITYID emailsender-apikey=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-apikey,identityref:$MANAGEDIDENTITYID \
+ emailsender-secret=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-secretkey,identityref:$MANAGEDIDENTITYID emailsender-name=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromname,identityref:$MANAGEDIDENTITYID \
+ emailsender-email=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromemail,identityref:$MANAGEDIDENTITYID connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings-essentialcsharpwebcontextconnection,identityref:$MANAGEDIDENTITYID \
+ captcha-sitekey=keyvaultref:$KEYVAULTURI/secrets/captcha-sitekey,identityref:$MANAGEDIDENTITYID captcha-secretkey=keyvaultref:$KEYVAULTURI/secrets/captcha-secretkey,identityref:$MANAGEDIDENTITYID \
+ appinsights-connectionstring=keyvaultref:$KEYVAULTURI/secrets/applicationinsights-connectionstring,identityref:$MANAGEDIDENTITYID \
+ ai-endpoint=keyvaultref:$KEYVAULTURI/secrets/AIOptions--Endpoint,identityref:$MANAGEDIDENTITYID ai-apikey=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ApiKey,identityref:$MANAGEDIDENTITYID \
+ ai-vectordeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--VectorGenerationDeploymentName,identityref:$MANAGEDIDENTITYID ai-chatdeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ChatDeploymentName,identityref:$MANAGEDIDENTITYID \
+ ai-systemprompt=keyvaultref:$KEYVAULTURI/secrets/AIOptions--SystemPrompt,identityref:$MANAGEDIDENTITYID \
+ postgres-vectorstore-connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings--PostgresVectorDb,identityref:$MANAGEDIDENTITYID
+ az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \
+ Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \
+ AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \
+ AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \
+ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
+ AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring
+
+ - name: Logout of Azure CLI
+ if: always()
+ uses: azure/CLI@v2
+ with:
+ inlineScript: |
+ az logout
+ az cache purge
+ az account clear
+
deploy-production:
- if: github.event_name != 'pull_request'
+ if: github.event_name != 'pull_request_target' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
- needs: [build-and-test, deploy-development]
+ needs: [deploy-development]
environment:
- name: 'Production'
- url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+ name: "Production"
steps:
- - name: Download artifact from build job
- uses: actions/download-artifact@v3
+ - name: Azure Login
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.ESSENTIALCSHARP_CLIENT_ID }}
+ tenant-id: ${{ secrets.ESSENTIALCSHARP_TENANT_ID }}
+ subscription-id: ${{ secrets.ESSENTIALCSHARP_SUBSCRIPTION_ID }}
+
+ - name: Download artifact
+ uses: actions/download-artifact@v7
+ with:
+ name: essentialcsharpwebimage
+ path: ${{ github.workspace }}
+
+ - name: Load image
+ run: |
+ docker load --input ${{ github.workspace }}/essentialcsharpwebimage.tar
+ docker image ls -a
+
+ - name: Log in to container registry
+ uses: docker/login-action@v3
with:
- name: .net-app
+ registry: ${{ vars.PRODCONTAINER_REGISTRY }}
+ username: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
+ password: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
+
+ - name: Push Image to Container Registry
+ run: docker push --all-tags ${{ vars.PRODCONTAINER_REGISTRY }}/essentialcsharpweb
+
+ - name: Create and Deploy to Container App
+ uses: azure/CLI@v2
+ env:
+ CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
+ RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
+ CONTAINER_REGISTRY: ${{ vars.PRODCONTAINER_REGISTRY }}
+ CONTAINER_APP_ENVIRONMENT: ${{ vars.CONTAINER_APP_ENVIRONMENT }}
+ ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
+ ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
+ with:
+ inlineScript: |
+ az config set extension.use_dynamic_install=yes_without_prompt
+ az containerapp up -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --image $CONTAINER_REGISTRY/essentialcsharpweb:${{ github.sha }} --environment $CONTAINER_APP_ENVIRONMENT --registry-server $CONTAINER_REGISTRY --ingress external --target-port 8080 --registry-username $ACR_USERNAME --registry-password $ACR_PASSWORD
+
+ - name: Assign Managed Identity to Container App and Set Secrets and Environment Variables
+ uses: azure/CLI@v2
+ env:
+ CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
+ RESOURCEGROUP: ${{ vars.RESOURCEGROUP }}
+ CONTAINER_REGISTRY: ${{ vars.PRODCONTAINER_REGISTRY }}
+ CONTAINER_APP_ENVIRONMENT: ${{ vars.CONTAINER_APP_ENVIRONMENT }}
+ KEYVAULTURI: ${{ secrets.ESSENTIALCSHARP_KEYVAULT_URI }}
+ MANAGEDIDENTITYID: ${{ secrets.ESSENTIALCSHARP_APPIDENTITY_ID }}
+ ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
+ ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
+ AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }}
+ with:
+ inlineScript: |
+ az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }}
+ az containerapp secret set -n $CONTAINER_APP_NAME -g $RESOURCEGROUP --secrets github-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientid,identityref:$MANAGEDIDENTITYID \
+ github-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-github-clientsecret,identityref:$MANAGEDIDENTITYID msft-clientid=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientid,identityref:$MANAGEDIDENTITYID \
+ msft-clientsecret=keyvaultref:$KEYVAULTURI/secrets/authentication-microsoft-clientsecret,identityref:$MANAGEDIDENTITYID emailsender-apikey=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-apikey,identityref:$MANAGEDIDENTITYID \
+ emailsender-secret=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-secretkey,identityref:$MANAGEDIDENTITYID emailsender-name=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromname,identityref:$MANAGEDIDENTITYID \
+ emailsender-email=keyvaultref:$KEYVAULTURI/secrets/authmessagesender-sendfromemail,identityref:$MANAGEDIDENTITYID connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings-essentialcsharpwebcontextconnection,identityref:$MANAGEDIDENTITYID \
+ captcha-sitekey=keyvaultref:$KEYVAULTURI/secrets/captcha-sitekey,identityref:$MANAGEDIDENTITYID captcha-secretkey=keyvaultref:$KEYVAULTURI/secrets/captcha-secretkey,identityref:$MANAGEDIDENTITYID \
+ appinsights-connectionstring=keyvaultref:$KEYVAULTURI/secrets/applicationinsights-connectionstring,identityref:$MANAGEDIDENTITYID \
+ ai-endpoint=keyvaultref:$KEYVAULTURI/secrets/AIOptions--Endpoint,identityref:$MANAGEDIDENTITYID ai-apikey=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ApiKey,identityref:$MANAGEDIDENTITYID \
+ ai-vectordeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--VectorGenerationDeploymentName,identityref:$MANAGEDIDENTITYID ai-chatdeployment=keyvaultref:$KEYVAULTURI/secrets/AIOptions--ChatDeploymentName,identityref:$MANAGEDIDENTITYID \
+ ai-systemprompt=keyvaultref:$KEYVAULTURI/secrets/AIOptions--SystemPrompt,identityref:$MANAGEDIDENTITYID \
+ postgres-vectorstore-connectionstring=keyvaultref:$KEYVAULTURI/secrets/connectionstrings--PostgresVectorDb,identityref:$MANAGEDIDENTITYID
+ az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \
+ Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \
+ AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \
+ AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \
+ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
+ AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring
+
- - name: Deploy to Azure Web App
- id: deploy-to-webapp
- uses: azure/webapps-deploy@v2
+ - name: Logout of Azure CLI
+ if: always()
+ uses: azure/CLI@v2
with:
- app-name: essentialcsharp
- slot-name: 'Production'
- publish-profile: ${{ secrets.AZURE_PRODUCTION_PUBLISH_PROFILE }}
- package: .
+ inlineScript: |
+ az logout
+ az cache purge
+ az account clear
diff --git a/.github/workflows/Build-Vector-DB.yml b/.github/workflows/Build-Vector-DB.yml
new file mode 100644
index 00000000..b8940e9a
--- /dev/null
+++ b/.github/workflows/Build-Vector-DB.yml
@@ -0,0 +1,26 @@
+name: Build Vector DB
+
+on:
+ push:
+ branches: ["main"]
+ pull_request_target:
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up Python 3.8
+ uses: actions/setup-python@v6
+ with:
+ python-version: 3.8
+ - name: Install dependencies
+ run: |
+ sudo apt-get install nuget
+ nuget config -Set repositoryPath=${{ github.workspace }}/packages -configfile ${{ github.workspace }}/nuget.config
+ nuget.exe setapikey ${{ secrets.NUGET_API_KEY }} -source "https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json"
+ nuget.exe restore ${{ github.workspace }}/EssentialCSharp.Chat/packages.config -PackagesDirectory ${{ github.workspace }}/packages
+ python -m pip install --upgrade pip
+ pip install -r ${{ github.workspace }}/EssentialCSharp.Chat/requirements.txt
+ ls ${{ github.workspace }}/packages
\ No newline at end of file
diff --git a/.github/workflows/CleanupCaches.yml b/.github/workflows/CleanupCaches.yml
index 0850e5de..bb16b019 100644
--- a/.github/workflows/CleanupCaches.yml
+++ b/.github/workflows/CleanupCaches.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
- name: Cleanup
run: |
diff --git a/.github/workflows/PR-Build-And-Test.yml b/.github/workflows/PR-Build-And-Test.yml
new file mode 100644
index 00000000..1fd7a1d2
--- /dev/null
+++ b/.github/workflows/PR-Build-And-Test.yml
@@ -0,0 +1,58 @@
+name: PR Build and Test EssentialCSharp.Web
+
+on:
+ pull_request:
+ branches: ["main"]
+ workflow_dispatch:
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+
+ - name: Set up dependency caching for faster builds
+ uses: actions/cache@v5
+ id: nuget-cache
+ with:
+ path: |
+ ~/.nuget/packages
+ ${{ github.workspace }}/**/obj/project.assets.json
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ ${{ runner.os }}-nuget-
+
+ - name: Restore with dotnet
+ run: dotnet restore /p:AccessToNugetFeed=false
+
+ - name: Build with dotnet
+ run: dotnet build --configuration Release --no-restore /p:AccessToNugetFeed=false
+
+ - name: Run .NET Tests
+ run: dotnet test --no-build --configuration Release --logger trx --results-directory ${{ runner.temp }}
+
+ - name: Convert TRX to VS Playlist
+ if: failure()
+ uses: BenjaminMichaelis/trx-to-vsplaylist@v3
+ with:
+ trx-file-path: '${{ runner.temp }}/*.trx'
+ output-directory: '${{ runner.temp }}/vsplaylists'
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build Container Image
+ uses: docker/build-push-action@v6
+ with:
+ file: ./EssentialCSharp.Web/Dockerfile
+ context: .
+ outputs: type=docker,dest=${{ github.workspace }}/essentialcsharpwebimage.tar
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: ACCESS_TO_NUGET_FEED=false
diff --git a/.github/workflows/UpdateSiteCertificate.yml b/.github/workflows/UpdateSiteCertificate.yml
new file mode 100644
index 00000000..06447661
--- /dev/null
+++ b/.github/workflows/UpdateSiteCertificate.yml
@@ -0,0 +1,100 @@
+name: Update Site Certificate
+
+on:
+ schedule:
+ - cron: "1 1 1 * *"
+ workflow_dispatch:
+
+permissions:
+ id-token: write
+ contents: read
+
+jobs:
+ update-site-certificate:
+ runs-on: ubuntu-latest
+ environment: WebCertificate
+
+ steps:
+ - name: Azure login
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+
+ - uses: actions/checkout@v6
+ with:
+ repository: "EssentialCSharp/EssentialCSharp.Web.CertbotDnsGoDaddy"
+ token: ${{ secrets.CERTBOTDNSGODADDY_PAT }}
+ # If testing, change this to the "staging" branch to not use up your Let's Encrypt quota for the week (which is very very small)
+ ref: "main"
+
+ - name: Install Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ cache: 'pip'
+
+ - name: Retrieve the key and add it to a file
+ env:
+ APIKEY_BASE64: ${{ secrets.GODADDY_APIKEY_BASE64 }}
+ run: |
+ echo $APIKEY_BASE64 | base64 --decode > godaddycreds.ini
+
+ - name: Run Certbot
+ run: |
+ pip install --upgrade pip
+ python3 -m venv .venv
+ source .venv/bin/activate
+ pip install -r requirements.txt
+ chmod 600 godaddycreds.ini
+ sudo .venv/bin/python -m main
+ deactivate
+
+ - name: Clear API File
+ if: '!cancelled()'
+ run: |
+ rm *ini
+
+ - name: Create Private Certificate
+ env:
+ CERT_PASSWORD: ${{ secrets.CERT_PASSWORD }}
+ run: |
+ sudo chmod 0755 /etc/letsencrypt/{live,archive}
+ sudo chown $USER: /etc/letsencrypt/live/essentialcsharp.com/privkey.pem
+ sudo chmod 0640 /etc/letsencrypt/live/essentialcsharp.com/privkey.pem
+ openssl pkcs12 -export -out certificate.pfx -inkey /etc/letsencrypt/live/essentialcsharp.com/privkey.pem -in /etc/letsencrypt/live/essentialcsharp.com/cert.pem -certfile /etc/letsencrypt/live/essentialcsharp.com/chain.pem -passout pass:"$CERT_PASSWORD" -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg SHA1
+
+ - name: Upload certificate
+ env:
+ CERT_PASSWORD: ${{ secrets.CERT_PASSWORD }}
+ RESOURCE_GROUP: ${{ vars.RESOURCE_GROUP }}
+ CONTAINER_APP_NAME: ${{ vars.CONTAINER_APP_NAME }}
+ CONTAINER_APP_ENVIRONMENT: ${{ vars.CONTAINER_APP_ENVIRONMENT }}
+ uses: azure/CLI@v2
+ with:
+ azcliversion: latest
+ inlineScript: |
+ az containerapp ssl upload --resource-group $RESOURCE_GROUP --name $CONTAINER_APP_NAME --environment $CONTAINER_APP_ENVIRONMENT --hostname essentialcsharp.com --certificate-file /home/runner/work/EssentialCSharp.Web/EssentialCSharp.Web/certificate.pfx --certificate-name essentialcsharp.comcertificate --password "$CERT_PASSWORD"
+
+ - name: Clear certificate directories
+ if: '!cancelled()'
+ run: |
+ sudo rm -rf /etc/letsencrypt
+
+ - name: Logout of Azure CLI
+ if: '!cancelled()'
+ uses: azure/CLI@v2
+ with:
+ inlineScript: |
+ az logout
+ az cache purge
+ az account clear
+
+ - name: Clear Azure PowerShell Context
+ if: '!cancelled()'
+ uses: azure/powershell@v2.0.0
+ with:
+ azPSVersion: "latest"
+ inlineScript: |
+ Clear-AzContext -Scope Process
\ No newline at end of file
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 00000000..324e67af
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,91 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '21 15 * * 5'
+
+jobs:
+ analyze-csharp:
+ name: Analyze C# (CodeQL)
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'csharp' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v5
+
+ - name: Set up dependency caching for faster builds
+ uses: actions/cache@v5
+ id: nuget-cache
+ with:
+ path: |
+ ~/.nuget/packages
+ ${{ github.workspace }}/**/obj/project.assets.json
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ ${{ runner.os }}-nuget-
+
+ - name: Restore with dotnet
+ run: |
+ dotnet restore /p:AccessToNugetFeed=false
+ dotnet build --configuration Release --no-restore --no-incremental /p:AccessToNugetFeed=false
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
+
+ analyze-non-compiled-languages:
+ name: Analyze Non-Compiled Languages (CodeQL)
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'javascript-typescript' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v4
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
new file mode 100644
index 00000000..0e685139
--- /dev/null
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -0,0 +1,66 @@
+name: Setup GitHub Copilot Agent Environment
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - '.github/workflows/copilot-setup-steps.yml'
+ pull_request:
+ paths:
+ - '.github/workflows/copilot-setup-steps.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ copilot-setup-steps:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up .NET Core
+ uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+ source-url: https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json
+ env:
+ NUGET_AUTH_TOKEN: ${{ secrets.AZURE_DEVOPS_PAT }}
+
+ - name: Set up dependency caching for faster builds
+ uses: actions/cache@v5
+ id: nuget-cache
+ with:
+ path: |
+ ~/.nuget/packages
+ ${{ github.workspace }}/**/obj/project.assets.json
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
+ ${{ runner.os }}-nuget-
+
+ - name: Restore with dotnet
+ run: dotnet restore
+
+ - name: Build with dotnet
+ run: dotnet build -p:ContinuousIntegrationBuild=True -p:ReleaseDateAttribute=True --configuration Release --no-restore
+
+ - name: Run .NET Tests
+ run: dotnet test --no-build --configuration Release
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Set up Node.js for frontend development
+ uses: actions/setup-node@v6
+ with:
+ node-version: '20'
+
+ - name: Install additional development tools
+ run: |
+ # Install common development tools that Copilot agents might need
+ echo "Installing additional tools for Copilot agent environment..."
+
+ # Install EF Core tools globally
+ dotnet tool install --global dotnet-ef
diff --git a/.gitignore b/.gitignore
index a250f036..bc7b55e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ bin
~*.tmp
~$*.dotm
+# Files to keep (primarily book content)
!.gitignore
!.gitattributes
!EssentialC#.dotx
@@ -27,29 +28,259 @@ bin
!Michaelis_Acknowledgments.docx
!Michaelis_Forward.docx
!Michaelis_TableOfContents.docx
+
+# Old or generated files to not commit
wwwroot/sitemap.xml
+wwwroot/Chapters
+EssentialCSharp.Web/wwwroot/Chapters
+EssentialCSharp.Web/wwwroot/sitemap.xml
+EssentialCSharp.Web/Chapters/
+Utilities/EssentialCSharp.Web/Chapters/
+Utilities/EssentialCSharp.Web/wwwroot/sitemap.xml
+Utilities/EssentialCSharp.Web/wwwroot/Chapters/
*.user
Utilities/Parser.Web/.local-chromium
-# Local History for Visual Studio Code
-.history/
+[Ee]xpress/
-# Visual Studio code coverage results
-*.coverage
-*.coveragexml
-Utilities/EssentialCSharp.Web/wwwroot/Chapters/
+# 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
-#JetBrains Rider IDE Settings
-*/.idea/
+# Click-Once directory
+publish/
-Utilities/EssentialCSharp.Web/Chapters/
+# 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
-Utilities/EssentialCSharp.Web/wwwroot/sitemap.xml
+# 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/
-Utilities/EssentialCSharpUtilities.lutconfig
+# 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
-EssentialCSharp.Web/Chapters/
+# 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
msbuild.binlog
+*.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
+!.vscode/mcp.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
+
+EssentialCSharp.Web/Markdown/
+
+EssentialCSharp.Web/Guidelines/
+
+# DevContainer environment files with sensitive data
+.devcontainer/.env
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..d5b04848
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,35 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ // Use IntelliSense to find out which attributes exist for C# debugging
+ // Use hover for the description of the existing attributes
+ // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
+ "name": ".NET Core Launch (web)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ // If you have changed target frameworks, make sure to update the program path.
+ "program": "${workspaceFolder}/EssentialCSharp.Web/bin/Debug/net9.0/EssentialCSharp.Web.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/EssentialCSharp.Web",
+ "stopAtEntry": false,
+ // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
+ "serverReadyAction": {
+ "action": "openExternally",
+ "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
+ },
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "sourceFileMap": {
+ "/Views": "${workspaceFolder}/Views"
+ }
+ },
+ {
+ "name": ".NET Core Attach",
+ "type": "coreclr",
+ "request": "attach"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/mcp.json b/.vscode/mcp.json
new file mode 100644
index 00000000..81ac25d9
--- /dev/null
+++ b/.vscode/mcp.json
@@ -0,0 +1,12 @@
+{
+ "servers": {
+ "context7": {
+ "type": "http",
+ "url": "https://mcp.context7.com/mcp"
+ },
+ "microsoftdocs": {
+ "type": "http",
+ "url": "https://learn.microsoft.com/api/mcp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000..57ccdc50
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,41 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/EssentialCSharp.Web.sln",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "publish",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "publish",
+ "${workspaceFolder}/EssentialCSharp.Web.sln",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary;ForceNoAlign"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "watch",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "watch",
+ "run",
+ "--project",
+ "${workspaceFolder}/EssentialCSharp.Web.sln"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
index 42f6809e..10592d1d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,13 +1,15 @@
enable
- 11
+ 12Recommendedenabletruetrue
+ LinuxTrue
+ 18e91e0d-ea2d-490f-b77e-ec008f9d09ec
-
\ No newline at end of file
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 00000000..5e885b1f
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,56 @@
+
+
+ true
+ false
+ 1.1.1.18576
+ false
+
+ https://api.nuget.org/v3/index.json;
+
+
+ $(RestoreSources);
+ https://pkgs.dev.azure.com/intelliTect/_packaging/EssentialCSharp/nuget/v3/index.json;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj
new file mode 100644
index 00000000..f51ecef9
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj
@@ -0,0 +1,20 @@
+ο»Ώ
+
+
+ net9.0
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..24dfdb53
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,294 @@
+using Azure.AI.OpenAI;
+using Azure.Core;
+using Azure.Identity;
+using EssentialCSharp.Chat.Common.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Http.Resilience;
+using Microsoft.SemanticKernel;
+using Npgsql;
+using Polly;
+
+namespace EssentialCSharp.Chat.Common.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ private static readonly string[] PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"];
+
+ ///
+ /// Adds Azure OpenAI and related AI services to the service collection using Managed Identity
+ ///
+ /// The service collection to add services to
+ /// The AI configuration options
+ /// The PostgreSQL connection string for the vector store
+ /// The token credential to use for authentication. If null, DefaultAzureCredential will be used.
+ /// The service collection for chaining
+ public static IServiceCollection AddAzureOpenAIServices(
+ this IServiceCollection services,
+ AIOptions aiOptions,
+ string postgresConnectionString,
+ TokenCredential? credential = null)
+ {
+ // Use DefaultAzureCredential if no credential is provided
+ // This works both locally (using Azure CLI, Visual Studio, etc.) and in Azure (using Managed Identity)
+ credential ??= new DefaultAzureCredential();
+
+ if (string.IsNullOrEmpty(aiOptions.Endpoint))
+ {
+ throw new InvalidOperationException("AIOptions.Endpoint is required.");
+ }
+
+ var endpoint = new Uri(aiOptions.Endpoint);
+
+ // Configure HTTP resilience for Azure OpenAI requests
+ ConfigureAzureOpenAIResilience(services);
+
+ // Register Azure OpenAI services with Managed Identity authentication
+#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ services.AddAzureOpenAIChatClient(
+ aiOptions.ChatDeploymentName,
+ endpoint.ToString(),
+ credential);
+
+ services.AddSingleton(provider =>
+ new AzureOpenAIClient(endpoint, credential));
+
+ services.AddAzureOpenAIChatCompletion(
+ aiOptions.ChatDeploymentName,
+ aiOptions.Endpoint,
+ credential);
+
+ // Add PostgreSQL vector store with managed identity support
+ services.AddPostgresVectorStoreWithManagedIdentity(postgresConnectionString, credential);
+
+ services.AddAzureOpenAIEmbeddingGenerator(
+ aiOptions.VectorGenerationDeploymentName,
+ aiOptions.Endpoint,
+ credential);
+#pragma warning restore SKEXP0010
+
+ // Register shared AI services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
+ }
+
+ ///
+ /// Configures HTTP resilience (retry, circuit breaker, timeout) for Azure OpenAI HTTP clients.
+ /// This handles rate limiting (HTTP 429) and transient errors with exponential backoff.
+ ///
+ /// The service collection to configure
+ ///
+ /// This method configures resilience for ALL HTTP clients created via IHttpClientFactory.
+ ///
+ /// IMPORTANT: The Semantic Kernel's AddAzureOpenAI* extension methods (used in this class)
+ /// do NOT expose options to configure specific named or typed HttpClients. The internal
+ /// implementation creates HttpClient instances through IHttpClientFactory without
+ /// providing hooks for per-client configuration. Therefore, ConfigureHttpClientDefaults
+ /// is the ONLY way to apply resilience to Azure OpenAI clients when using Semantic Kernel.
+ ///
+ /// For Azure OpenAI services specifically, the resilience configuration:
+ /// - Retries HTTP 429 (rate limit), 408 (timeout), and 5xx errors
+ /// - Respects Retry-After headers from Azure OpenAI
+ /// - Uses exponential backoff with jitter
+ /// - Implements circuit breaker pattern
+ ///
+ /// This is appropriate for applications that primarily use Azure OpenAI services.
+ /// The retry policies are reasonable for most HTTP APIs and should not negatively
+ /// impact other HTTP clients like hCaptcha or Mailjet.
+ ///
+ private static void ConfigureAzureOpenAIResilience(IServiceCollection services)
+ {
+ // Configure resilience for all HTTP clients created via IHttpClientFactory
+ // The Semantic Kernel's AddAzureOpenAI* methods do not support named/typed
+ // HttpClient configuration, so ConfigureHttpClientDefaults is required.
+ services.ConfigureHttpClientDefaults(httpClientBuilder =>
+ {
+ httpClientBuilder.AddStandardResilienceHandler(options =>
+ {
+ // Configure retry strategy for rate limiting and transient errors
+ options.Retry.MaxRetryAttempts = 5;
+ options.Retry.Delay = TimeSpan.FromSeconds(2);
+ options.Retry.BackoffType = DelayBackoffType.Exponential;
+ options.Retry.UseJitter = true;
+
+ // The standard resilience handler already handles:
+ // - HTTP 429 (Too Many Requests / Rate Limit)
+ // - HTTP 408 (Request Timeout)
+ // - HTTP 5xx (Server Errors)
+ // - Respects Retry-After header automatically
+
+ // Configure circuit breaker to prevent overwhelming the service
+ options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
+ options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
+ options.CircuitBreaker.FailureRatio = 0.2; // Break if 20% of requests fail
+
+ // Configure timeout for individual attempts
+ options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(30);
+
+ // Configure total timeout for all retry attempts
+ options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(3);
+ });
+ });
+ }
+
+ ///
+ /// Adds Azure OpenAI and related AI services to the service collection using configuration
+ ///
+ /// The service collection to add services to
+ /// The configuration to read AIOptions from
+ /// Optional token credential to use for authentication. If null, DefaultAzureCredential will be used.
+ /// The service collection for chaining
+ public static IServiceCollection AddAzureOpenAIServices(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ TokenCredential? credential = null)
+ {
+ // Configure AI options from configuration
+ services.Configure(configuration.GetSection("AIOptions"));
+
+ var aiOptions = configuration.GetSection("AIOptions").Get();
+ if (aiOptions == null)
+ {
+ throw new InvalidOperationException("AIOptions section is missing from configuration.");
+ }
+
+ // Get PostgreSQL connection string using the standard method
+ var postgresConnectionString = configuration.GetConnectionString("PostgresVectorStore") ??
+ throw new InvalidOperationException("Connection string 'PostgresVectorStore' not found.");
+
+ return services.AddAzureOpenAIServices(aiOptions, postgresConnectionString, credential);
+ }
+
+ ///
+ /// Adds PostgreSQL vector store with managed identity authentication support.
+ /// NOTE: Token is obtained once at startup and will expire after ~1 hour.
+ /// For long-running applications, consider implementing token refresh logic.
+ ///
+ /// The service collection to add services to
+ /// The PostgreSQL connection string (without password)
+ /// The token credential to use for authentication. If null, DefaultAzureCredential will be used.
+ /// The service collection for chaining
+ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity(
+ this IServiceCollection services,
+ string connectionString,
+ TokenCredential? credential = null)
+ {
+ credential ??= new DefaultAzureCredential();
+
+ // Parse the connection string to extract host, database, and username
+ var builder = new NpgsqlConnectionStringBuilder(connectionString);
+
+ // Check if this is an Azure PostgreSQL connection (contains .postgres.database.azure.com)
+ bool isAzurePostgres = builder.Host?.Contains(".postgres.database.azure.com", StringComparison.OrdinalIgnoreCase) ?? false;
+
+ if (isAzurePostgres && string.IsNullOrEmpty(builder.Password))
+ {
+ // Get access token for Azure PostgreSQL using managed identity
+ var tokenRequestContext = new TokenRequestContext(PostgresScopes);
+ var accessToken = credential.GetToken(tokenRequestContext, default);
+
+ // Set the password to the access token
+ builder.Password = accessToken.Token;
+
+ // Ensure SSL is enabled for Azure
+ if (builder.SslMode == SslMode.Disable)
+ {
+ builder.SslMode = SslMode.Require;
+ }
+
+ connectionString = builder.ToString();
+ }
+
+ // Register NpgsqlDataSource with UseVector() enabled - this is critical for pgvector support
+ services.AddSingleton(sp =>
+ {
+ var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
+ // IMPORTANT: UseVector() must be called to enable pgvector support
+ dataSourceBuilder.UseVector();
+ return dataSourceBuilder.Build();
+ });
+
+ // Register the vector store using the NpgsqlDataSource from DI
+#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ services.AddPostgresVectorStore();
+#pragma warning restore SKEXP0010
+
+ return services;
+ }
+
+ ///
+ /// Adds Azure OpenAI and related AI services to the service collection using API key authentication (legacy)
+ ///
+ /// The service collection to add services to
+ /// The AI configuration options
+ /// The PostgreSQL connection string for the vector store
+ /// The API key for Azure OpenAI authentication
+ /// The service collection for chaining
+ [Obsolete("API key authentication is not recommended for production. Use AddAzureOpenAIServices with Managed Identity instead.")]
+ public static IServiceCollection AddAzureOpenAIServicesWithApiKey(
+ this IServiceCollection services,
+ AIOptions aiOptions,
+ string postgresConnectionString,
+ string apiKey)
+ {
+ if (string.IsNullOrEmpty(apiKey))
+ {
+ throw new ArgumentException("API key cannot be null or empty.", nameof(apiKey));
+ }
+
+ if (string.IsNullOrEmpty(aiOptions.Endpoint))
+ {
+ throw new InvalidOperationException("AIOptions.Endpoint is required.");
+ }
+
+ var endpoint = new Uri(aiOptions.Endpoint);
+
+ // Configure HTTP resilience for Azure OpenAI requests
+ ConfigureAzureOpenAIResilience(services);
+
+ // Register Azure OpenAI services with API key authentication
+#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ services.AddAzureOpenAIChatClient(
+ aiOptions.ChatDeploymentName,
+ aiOptions.Endpoint,
+ apiKey);
+
+ services.AddSingleton(provider =>
+ new AzureOpenAIClient(endpoint, new Azure.AzureKeyCredential(apiKey)));
+
+ services.AddAzureOpenAIChatCompletion(
+ aiOptions.ChatDeploymentName,
+ aiOptions.Endpoint,
+ apiKey);
+
+ // Register NpgsqlDataSource with UseVector() enabled for API key scenario as well
+ services.AddSingleton(sp =>
+ {
+ var dataSourceBuilder = new NpgsqlDataSourceBuilder(postgresConnectionString);
+ // IMPORTANT: UseVector() must be called to enable pgvector support
+ dataSourceBuilder.UseVector();
+ return dataSourceBuilder.Build();
+ });
+
+ // Add PostgreSQL vector store using the NpgsqlDataSource from DI
+ services.AddPostgresVectorStore();
+
+ services.AddAzureOpenAIEmbeddingGenerator(
+ aiOptions.VectorGenerationDeploymentName,
+ aiOptions.Endpoint,
+ apiKey);
+#pragma warning restore SKEXP0010
+
+ // Register shared AI services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
+ }
+}
diff --git a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs
new file mode 100644
index 00000000..290b49ab
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs
@@ -0,0 +1,29 @@
+ο»Ώnamespace EssentialCSharp.Chat;
+
+public class AIOptions
+{
+ ///
+ /// The Azure OpenAI deployment name for text embedding generation.
+ ///
+ public string VectorGenerationDeploymentName { get; set; } = string.Empty;
+
+ ///
+ /// The Azure OpenAI deployment name for chat completions.
+ ///
+ public string ChatDeploymentName { get; set; } = string.Empty;
+
+ ///
+ /// The system prompt to use for the chat model.
+ ///
+ public string SystemPrompt { get; set; } = string.Empty;
+
+ ///
+ /// The Azure OpenAI endpoint URL.
+ ///
+ public string Endpoint { get; set; } = string.Empty;
+
+ ///
+ /// The API key for accessing Azure OpenAI services.
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+}
diff --git a/EssentialCSharp.Chat.Shared/Models/BookContentChunk.cs b/EssentialCSharp.Chat.Shared/Models/BookContentChunk.cs
new file mode 100644
index 00000000..e70ac015
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Models/BookContentChunk.cs
@@ -0,0 +1,56 @@
+using Microsoft.Extensions.VectorData;
+
+namespace EssentialCSharp.Chat.Common.Models;
+
+///
+/// Represents a chunk of book content for vector search
+///
+public sealed class BookContentChunk
+{
+ ///
+ /// Unique identifier for the chunk - serves as the vector store key
+ ///
+ [VectorStoreKey]
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Original source file name
+ ///
+ [VectorStoreData]
+ public string FileName { get; set; } = string.Empty;
+
+ ///
+ /// Heading or title of the markdown chunk
+ ///
+ [VectorStoreData]
+ public string Heading { get; set; } = string.Empty;
+
+ ///
+ /// The actual markdown content text for this chunk
+ ///
+ [VectorStoreData]
+ public string ChunkText { get; set; } = string.Empty;
+
+ ///
+ /// Chapter number extracted from filename (e.g., "Chapter01.md" -> 1)
+ ///
+ [VectorStoreData]
+ public int? ChapterNumber { get; set; }
+
+ ///
+ /// SHA256 hash of the chunk content for change detection
+ ///
+ [VectorStoreData]
+ public string ContentHash { get; set; } = string.Empty;
+
+ ///
+ /// Vector embedding for the chunk text - will be generated by embedding service
+ /// Using 1536 dimensions for Azure OpenAI text-embedding-3-small
+ /// Note: HNSW index in Semantic Kernel PostgreSQL connector supports max 2000 dimensions
+ /// https://github.com/pgvector/pgvector/issues/461
+ /// Use CosineSimilarity distance function since we are using text-embedding-3 (https://platform.openai.com/docs/guides/embeddings#which-distance-function-should-i-use)
+ /// Postgres supports only Hnsw: https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/out-of-the-box-connectors/postgres-connector?pivots=programming-language-csharp&WT.mc_id=8B97120A00B57354
+ ///
+ [VectorStoreVector(Dimensions: 1536, DistanceFunction = DistanceFunction.CosineSimilarity, IndexKind = IndexKind.Hnsw)]
+ public ReadOnlyMemory? TextEmbedding { get; set; }
+}
diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs
new file mode 100644
index 00000000..8721d966
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs
@@ -0,0 +1,334 @@
+using Azure.AI.OpenAI;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using OpenAI.Responses;
+
+namespace EssentialCSharp.Chat.Common.Services;
+
+///
+/// Service for handling AI chat completions using the OpenAI Responses API
+///
+public class AIChatService
+{
+ private readonly AIOptions _Options;
+ private readonly AzureOpenAIClient _AzureClient;
+ private readonly OpenAIResponseClient _ResponseClient;
+ private readonly AISearchService _SearchService;
+
+ public AIChatService(IOptions options, AISearchService searchService, AzureOpenAIClient azureClient)
+ {
+ _Options = options.Value;
+ _SearchService = searchService;
+
+ // Initialize Azure OpenAI client and get the Response Client from it
+ _AzureClient = azureClient;
+
+ _ResponseClient = _AzureClient.GetOpenAIResponseClient(_Options.ChatDeploymentName);
+ }
+
+ ///
+ /// Gets a single chat completion response with all optional features
+ ///
+ /// The user's input prompt
+ /// Optional system prompt to override the default
+ /// Previous response ID to maintain conversation context
+ /// Optional tools for the AI to use
+ /// Optional reasoning effort level for reasoning models
+ /// Enable vector search for contextual information
+ /// Cancellation token
+ /// The AI response text and response ID for conversation continuity
+ public async Task<(string response, string responseId)> GetChatCompletion(
+ string prompt,
+ string? systemPrompt = null,
+ string? previousResponseId = null,
+ IMcpClient? mcpClient = null,
+ IEnumerable? tools = null,
+ ResponseReasoningEffortLevel? reasoningEffortLevel = null,
+ bool enableContextualSearch = false,
+ CancellationToken cancellationToken = default)
+ {
+ var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken);
+ var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
+ return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, cancellationToken);
+ }
+
+ ///
+ /// Gets a streaming chat completion response with all optional features
+ ///
+ /// The user's input prompt
+ /// Optional system prompt to override the default
+ /// Previous response ID to maintain conversation context
+ /// Optional tools for the AI to use
+ /// Optional reasoning effort level for reasoning models
+ /// Enable vector search for contextual information
+ /// Cancellation token
+ /// An async enumerable of response text chunks and final response ID
+ public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
+ string prompt,
+ string? systemPrompt = null,
+ string? previousResponseId = null,
+ IMcpClient? mcpClient = null,
+ IEnumerable? tools = null,
+ ResponseReasoningEffortLevel? reasoningEffortLevel = null,
+ bool enableContextualSearch = false,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken);
+ var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
+
+ // Construct the user input with system context if provided
+ var systemContext = systemPrompt ?? _Options.SystemPrompt;
+
+ // Create the streaming response using the Responses API
+ List responseItems = [ResponseItem.CreateUserMessageItem(enrichedPrompt)];
+ if (systemContext is not null)
+ {
+ responseItems.Add(
+ ResponseItem.CreateSystemMessageItem(systemContext));
+ }
+ var streamingUpdates = _ResponseClient.CreateResponseStreamingAsync(
+ responseItems,
+ options: responseOptions,
+ cancellationToken: cancellationToken);
+
+ await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken))
+ {
+ yield return result;
+ }
+ }
+
+ ///
+ /// Enriches the user prompt with contextual information from vector search
+ ///
+ private async Task EnrichPromptWithContext(string prompt, bool enableContextualSearch, CancellationToken cancellationToken)
+ {
+ if (!enableContextualSearch)
+ {
+ return prompt;
+ }
+
+ var searchResults = await _SearchService.ExecuteVectorSearch(prompt);
+ var contextualInfo = new System.Text.StringBuilder();
+
+ contextualInfo.AppendLine("## Contextual Information");
+ contextualInfo.AppendLine("The following information might be relevant to your question:");
+ contextualInfo.AppendLine();
+
+ await foreach (var result in searchResults)
+ {
+ contextualInfo.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $"**From: {result.Record.Heading}**");
+ contextualInfo.AppendLine(result.Record.ChunkText);
+ contextualInfo.AppendLine();
+ }
+
+ contextualInfo.AppendLine("## User Question");
+ contextualInfo.AppendLine(prompt);
+
+ return contextualInfo.ToString();
+ }
+
+ ///
+ /// Processes streaming updates from the OpenAI Responses API, handling both regular responses and function calls
+ ///
+ private async IAsyncEnumerable<(string text, string? responseId)> ProcessStreamingUpdatesAsync(
+ IAsyncEnumerable streamingUpdates,
+ ResponseCreationOptions responseOptions,
+ IMcpClient? mcpClient,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
+ {
+ string? responseId;
+ if (update is StreamingResponseCreatedUpdate created)
+ {
+ // Remember the response ID for later function calls
+ responseId = created.Response.Id;
+ }
+ else if (update is StreamingResponseOutputItemDoneUpdate itemDone)
+ {
+ // Check if this is a function call that needs to be executed
+ if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
+ {
+ // Execute the function call and stream its response
+ await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken))
+ {
+ if (functionResult.responseId != null)
+ {
+ responseId = functionResult.responseId;
+ }
+ yield return functionResult;
+ }
+ }
+ }
+ else if (update is StreamingResponseOutputTextDeltaUpdate deltaUpdate)
+ {
+ yield return (deltaUpdate.Delta.ToString(), null);
+ }
+ else if (update is StreamingResponseCompletedUpdate completedUpdate)
+ {
+ yield return (string.Empty, responseId: completedUpdate.Response.Id); // Signal completion with response ID
+ }
+ }
+ }
+
+ ///
+ /// Executes a function call and streams the response
+ ///
+ private async IAsyncEnumerable<(string text, string? responseId)> ExecuteFunctionCallAsync(
+ FunctionCallResponseItem functionCallItem,
+ ResponseCreationOptions responseOptions,
+ IMcpClient mcpClient,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
+ Dictionary arguments = [];
+ // example JsonResponse:
+ // "{\"question\":\"Azure OpenAI Responses API (Preview)\"}"
+ var jsonResponse = functionCallItem.FunctionArguments.ToString();
+ var jsonArguments = System.Text.Json.JsonSerializer.Deserialize>(jsonResponse) ?? new Dictionary();
+
+ // Convert JsonElement values to their actual types
+ foreach (var kvp in jsonArguments)
+ {
+ if (kvp.Value is System.Text.Json.JsonElement jsonElement)
+ {
+ arguments[kvp.Key] = jsonElement.ValueKind switch
+ {
+ System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
+ System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
+ System.Text.Json.JsonValueKind.True => true,
+ System.Text.Json.JsonValueKind.False => false,
+ System.Text.Json.JsonValueKind.Null => null,
+ _ => jsonElement.ToString()
+ };
+ }
+ else
+ {
+ arguments[kvp.Key] = kvp.Value;
+ }
+ }
+
+ // Execute the function call using the MCP client
+ var toolResult = await mcpClient.CallToolAsync(
+ functionCallItem.FunctionName,
+ arguments: arguments,
+ cancellationToken: cancellationToken);
+
+ // Create input items with both the function call and the result
+ // This matches the Python pattern: append both tool_call and result
+ var inputItems = new List
+ {
+ functionCallItem, // The original function call
+ new FunctionCallOutputResponseItem(functionCallItem.CallId, string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType().Select(x => x.Text)))
+ };
+
+ // Stream the function call response using the same processing logic
+ var functionResponseStream = _ResponseClient.CreateResponseStreamingAsync(
+ inputItems,
+ responseOptions,
+ cancellationToken);
+
+ await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken))
+ {
+ yield return result;
+ }
+ }
+
+ ///
+ /// Creates response options with optional features
+ ///
+ private static async Task CreateResponseOptionsAsync(
+ string? previousResponseId = null,
+ IEnumerable? tools = null,
+ ResponseReasoningEffortLevel? reasoningEffortLevel = null,
+ IMcpClient? mcpClient = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var options = new ResponseCreationOptions();
+
+ // Add conversation context if available
+ if (!string.IsNullOrEmpty(previousResponseId))
+ {
+ options.PreviousResponseId = previousResponseId;
+ }
+
+ // Add tools if provided
+ if (tools != null)
+ {
+ foreach (var tool in tools)
+ {
+ options.Tools.Add(tool);
+ }
+ }
+
+ if (mcpClient is not null)
+ {
+ await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken))
+ {
+ options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, tool.Description, BinaryData.FromString(tool.JsonSchema.GetRawText())));
+ }
+ }
+
+ // Add reasoning options if specified
+ if (reasoningEffortLevel.HasValue)
+ {
+ options.ReasoningOptions = new ResponseReasoningOptions()
+ {
+ ReasoningEffortLevel = reasoningEffortLevel.Value
+ };
+ }
+
+ return options;
+ }
+
+ ///
+ /// Core method for getting chat completions with configurable response options
+ ///
+ private async Task<(string response, string responseId)> GetChatCompletionCore(
+ string prompt,
+ ResponseCreationOptions responseOptions,
+ string? systemPrompt = null,
+ CancellationToken cancellationToken = default)
+ {
+ // Construct the user input with system context if provided
+ var systemContext = systemPrompt ?? _Options.SystemPrompt;
+
+ // Create the streaming response using the Responses API
+ List responseItems = [ResponseItem.CreateUserMessageItem(prompt)];
+ if (systemContext is not null)
+ {
+ responseItems.Add(
+ ResponseItem.CreateSystemMessageItem(systemContext));
+ }
+
+ // Create the response using the Responses API
+ var response = await _ResponseClient.CreateResponseAsync(
+ responseItems,
+ options: responseOptions,
+ cancellationToken: cancellationToken);
+
+ // Extract the message content and response ID
+ string responseText = string.Empty;
+ string responseId = response.Value.Id;
+
+ foreach (var outputItem in response.Value.OutputItems)
+ {
+ if (outputItem is MessageResponseItem messageItem &&
+ messageItem.Role == MessageRole.Assistant)
+ {
+ var textContent = messageItem.Content?.FirstOrDefault()?.Text;
+ if (!string.IsNullOrEmpty(textContent))
+ {
+ responseText = textContent;
+ break;
+ }
+ }
+ }
+
+ return (responseText, responseId);
+ }
+
+ // TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai)
+}
diff --git a/EssentialCSharp.Chat.Shared/Services/AISearchService.cs b/EssentialCSharp.Chat.Shared/Services/AISearchService.cs
new file mode 100644
index 00000000..915d1dc4
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Services/AISearchService.cs
@@ -0,0 +1,27 @@
+ο»Ώusing EssentialCSharp.Chat.Common.Models;
+using Microsoft.Extensions.VectorData;
+
+namespace EssentialCSharp.Chat.Common.Services;
+
+public class AISearchService(VectorStore vectorStore, EmbeddingService embeddingService)
+{
+ // TODO: Implement Hybrid Search functionality, may need to switch db providers to support full text search?
+
+ public async Task>> ExecuteVectorSearch(string query, string? collectionName = null)
+ {
+ collectionName ??= EmbeddingService.CollectionName;
+
+ VectorStoreCollection collection = vectorStore.GetCollection(collectionName);
+
+ ReadOnlyMemory searchVector = await embeddingService.GenerateEmbeddingAsync(query);
+
+ var vectorSearchOptions = new VectorSearchOptions
+ {
+ VectorProperty = x => x.TextEmbedding,
+ };
+
+ var searchResults = collection.SearchAsync(searchVector, options: vectorSearchOptions, top: 3);
+
+ return searchResults;
+ }
+}
diff --git a/EssentialCSharp.Chat.Shared/Services/ChunkingResultExtensions.cs b/EssentialCSharp.Chat.Shared/Services/ChunkingResultExtensions.cs
new file mode 100644
index 00000000..a350823a
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Services/ChunkingResultExtensions.cs
@@ -0,0 +1,61 @@
+using System.Security.Cryptography;
+using System.Text;
+using EssentialCSharp.Chat.Common.Models;
+
+namespace EssentialCSharp.Chat.Common.Services;
+
+public static partial class ChunkingResultExtensions
+{
+ public static List ToBookContentChunks(this FileChunkingResult result)
+ {
+ var chunks = new List();
+ int? chapterNumber = ExtractChapterNumber(result.FileName);
+
+ foreach (var chunk in result.Chunks)
+ {
+ string chunkText = chunk;
+ string contentHash = ComputeSha256Hash(chunkText);
+
+ chunks.Add(new BookContentChunk
+ {
+ Id = Guid.NewGuid().ToString(),
+ FileName = result.FileName,
+ Heading = ExtractHeading(chunkText),
+ ChunkText = chunkText,
+ ChapterNumber = chapterNumber,
+ ContentHash = contentHash
+ });
+ }
+ return chunks;
+ }
+
+ private static string ExtractHeading(string chunkText)
+ {
+ // get characters until the first " - " or newline
+ var firstLine = chunkText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)[0];
+ var headingParts = firstLine.Split([" - "], StringSplitOptions.None);
+ return headingParts.Length > 0 ? headingParts[0].Trim() : string.Empty;
+ }
+
+ private static int ExtractChapterNumber(string fileName)
+ {
+ // Example: "Chapter01.md" -> 1
+ // Regex: Chapter(?[0-9]{2})
+ var match = ChapterNumberRegex().Match(fileName);
+ if (match.Success && int.TryParse(match.Groups["ChapterNumber"].Value, out int chapterNumber))
+
+ {
+ return chapterNumber;
+ }
+ throw new InvalidOperationException($"File name '{fileName}' does not contain a valid chapter number in the expected format.");
+ }
+
+ private static string ComputeSha256Hash(string text)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
+ return Convert.ToHexStringLower(bytes);
+ }
+
+ [System.Text.RegularExpressions.GeneratedRegex(@"Chapter(?\d{2})")]
+ private static partial System.Text.RegularExpressions.Regex ChapterNumberRegex();
+}
diff --git a/EssentialCSharp.Chat.Shared/Services/EmbeddingService.cs b/EssentialCSharp.Chat.Shared/Services/EmbeddingService.cs
new file mode 100644
index 00000000..2d069318
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Services/EmbeddingService.cs
@@ -0,0 +1,59 @@
+using EssentialCSharp.Chat.Common.Models;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+
+namespace EssentialCSharp.Chat.Common.Services;
+
+///
+/// Service for generating embeddings for markdown chunks using Azure OpenAI
+///
+public class EmbeddingService(VectorStore vectorStore, IEmbeddingGenerator> embeddingGenerator)
+{
+ public static string CollectionName { get; } = "markdown_chunks";
+
+ ///
+ /// Generate an embedding for the given text.
+ ///
+ /// The text to generate an embedding for.
+ /// The cancellation token.
+ /// A search vector as ReadOnlyMemory<float>.
+ public async Task> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken = default)
+ {
+ var embedding = await embeddingGenerator.GenerateAsync(text, cancellationToken: cancellationToken);
+ return embedding.Vector;
+ }
+
+ ///
+ /// Generate an embedding for each text paragraph and upload it to the specified collection.
+ ///
+ /// The name of the collection to upload the text paragraphs to.
+ /// An async task.
+ public async Task GenerateBookContentEmbeddingsAndUploadToVectorStore(IEnumerable bookContents, CancellationToken cancellationToken, string? collectionName = null)
+ {
+ collectionName ??= CollectionName;
+
+ var collection = vectorStore.GetCollection(collectionName);
+ await collection.EnsureCollectionDeletedAsync(cancellationToken);
+ await collection.EnsureCollectionExistsAsync(cancellationToken);
+
+ ParallelOptions parallelOptions = new()
+ {
+ MaxDegreeOfParallelism = 5,
+ CancellationToken = cancellationToken
+ };
+
+ int uploadedCount = 0;
+
+ await Parallel.ForEachAsync(bookContents, parallelOptions, async (chunk, cancellationToken) =>
+ {
+ // Generate the text embedding using the new method.
+ chunk.TextEmbedding = await GenerateEmbeddingAsync(chunk.ChunkText, cancellationToken);
+
+ await collection.UpsertAsync(chunk, cancellationToken);
+ Console.WriteLine($"Uploaded chunk '{chunk.Id}' to collection '{collectionName}' for file '{chunk.FileName}' with heading '{chunk.Heading}'.");
+
+ Interlocked.Increment(ref uploadedCount);
+ });
+ Console.WriteLine($"Successfully generated embeddings and uploaded {uploadedCount} chunks to collection '{collectionName}'.");
+ }
+}
diff --git a/EssentialCSharp.Chat.Shared/Services/FileChunkingResult.cs b/EssentialCSharp.Chat.Shared/Services/FileChunkingResult.cs
new file mode 100644
index 00000000..e2d0f40e
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Services/FileChunkingResult.cs
@@ -0,0 +1,14 @@
+namespace EssentialCSharp.Chat.Common.Services;
+
+///
+/// Data structure to hold chunking results for a single file
+///
+public class FileChunkingResult
+{
+ public string FileName { get; set; } = string.Empty;
+ public string FilePath { get; set; } = string.Empty;
+ public int OriginalCharCount { get; set; }
+ public int ChunkCount { get; set; }
+ public List Chunks { get; set; } = [];
+ public int TotalChunkCharacters { get; set; }
+}
diff --git a/EssentialCSharp.Chat.Shared/Services/MarkdownChunkingService.cs b/EssentialCSharp.Chat.Shared/Services/MarkdownChunkingService.cs
new file mode 100644
index 00000000..d50ee214
--- /dev/null
+++ b/EssentialCSharp.Chat.Shared/Services/MarkdownChunkingService.cs
@@ -0,0 +1,180 @@
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using Microsoft.SemanticKernel.Text;
+
+namespace EssentialCSharp.Chat.Common.Services;
+
+///
+/// Markdown chunking service using Semantic Kernel's TextChunker
+///
+public partial class MarkdownChunkingService(
+ ILogger logger,
+ int maxTokensPerChunk = 256,
+ int overlapTokens = 25)
+{
+ private static readonly string[] _NewLineSeparators = ["\r\n", "\n", "\r"];
+ private readonly int _MaxTokensPerChunk = maxTokensPerChunk;
+ private readonly int _OverlapTokens = overlapTokens;
+
+ ///
+ /// Process markdown files in the specified directory using Semantic Kernel's TextChunker
+ ///
+ public async Task> ProcessMarkdownFilesAsync(
+ DirectoryInfo directory,
+ string filePattern)
+ {
+ // Validate input parameters
+ if (!directory.Exists)
+ {
+ logger.LogError("Error: Directory {DirectoryName} does not exist.", directory.FullName);
+ throw new InvalidOperationException($"Error: Directory '{directory.FullName}' does not exist.");
+ }
+
+ // Find markdown files
+ var markdownFiles = directory.GetFiles(filePattern, SearchOption.TopDirectoryOnly);
+
+ if (markdownFiles.Length == 0)
+ {
+ throw new InvalidOperationException($"No files matching pattern '{filePattern}' found in '{directory.FullName}'");
+ }
+
+ Console.WriteLine($"Processing {markdownFiles.Length} markdown files...");
+
+ int totalChunks = 0;
+ var results = new List();
+
+ foreach (var file in markdownFiles)
+ {
+ string[] fileContent = await File.ReadAllLinesAsync(file.FullName);
+ var result = ProcessSingleMarkdownFile(fileContent, file.Name, file.FullName);
+ results.Add(result);
+ totalChunks += result.ChunkCount;
+ }
+ Console.WriteLine($"Processed {markdownFiles.Length} markdown files with a total of {totalChunks} chunks.");
+
+ return results;
+ }
+
+ ///
+ /// Process a single markdown file using Semantic Kernel's SplitMarkdownParagraphs method
+ ///
+ public FileChunkingResult ProcessSingleMarkdownFile(
+ string[] fileContent, string fileName, string filePath)
+ {
+ // Remove all multiple empty lines so there is no more than one empty line between paragraphs
+ string[] lines = [.. fileContent
+ .Select(line => line.Trim())
+ .Where(line => !string.IsNullOrWhiteSpace(line))];
+
+ string content = string.Join(Environment.NewLine, lines);
+
+ var sections = MarkdownContentToHeadersAndSection(content);
+ var allChunks = new List();
+ int totalChunkCharacters = 0;
+ int chunkCount = 0;
+
+ foreach (var (Header, Content) in sections)
+ {
+#pragma warning disable SKEXP0050
+ var chunks = TextChunker.SplitMarkdownParagraphs(
+ lines: Content,
+ maxTokensPerParagraph: _MaxTokensPerChunk,
+ overlapTokens: _OverlapTokens,
+ chunkHeader: Header + " - "
+ );
+#pragma warning restore SKEXP0050
+ allChunks.AddRange(chunks);
+ chunkCount += chunks.Count;
+ totalChunkCharacters += chunks.Sum(c => c.Length);
+ }
+
+ return new FileChunkingResult
+ {
+ FileName = fileName,
+ FilePath = filePath,
+ OriginalCharCount = content.Length,
+ ChunkCount = chunkCount,
+ Chunks = allChunks,
+ TotalChunkCharacters = totalChunkCharacters
+ };
+ }
+
+ ///
+ /// Convert markdown content into a list of headers and their associated content sections.
+ ///
+ ///
+ ///
+ public static List<(string Header, List Content)> MarkdownContentToHeadersAndSection(string content)
+ {
+ var lines = content.Split(_NewLineSeparators, StringSplitOptions.None);
+ var sections = new List<(string Header, List Content)>();
+ var headerRegex = HeadingRegex();
+ var listingPattern = ListingRegex();
+ var headerStack = new List<(int Level, string Text)>();
+ int i = 0;
+ while (i < lines.Length)
+ {
+ // Find next header
+ while (i < lines.Length && !headerRegex.IsMatch(lines[i]))
+ i++;
+ if (i >= lines.Length) break;
+
+ var match = headerRegex.Match(lines[i]);
+ int level = match.Groups[1].Value.Length;
+ string headerText = match.Groups[2].Value.Trim();
+ bool isListing = headerText.StartsWith("Listing", StringComparison.OrdinalIgnoreCase) && listingPattern.IsMatch(headerText);
+
+ // If this is a listing header, append its content to the previous section
+ if (isListing && sections.Count > 0)
+ {
+ i++; // skip the listing header
+ var listingContent = new List();
+ while (i < lines.Length && !headerRegex.IsMatch(lines[i]))
+ {
+ if (!string.IsNullOrWhiteSpace(lines[i]))
+ listingContent.Add(lines[i]);
+ i++;
+ }
+ // Append to previous section's content
+ var prev = sections[^1];
+ prev.Content.AddRange(listingContent);
+ sections[^1] = prev;
+ continue;
+ }
+
+ // Update header stack for non-listing headers
+ if (headerStack.Count == 0 || level > headerStack.Last().Level)
+ {
+ headerStack.Add((level, headerText));
+ }
+ else
+ {
+ while (headerStack.Count > 0 && headerStack.Last().Level >= level)
+ headerStack.RemoveAt(headerStack.Count - 1);
+ headerStack.Add((level, headerText));
+ }
+ i++;
+
+ // Collect content until next header
+ var contentLines = new List();
+ while (i < lines.Length && !headerRegex.IsMatch(lines[i]))
+ {
+ if (!string.IsNullOrWhiteSpace(lines[i]))
+ contentLines.Add(lines[i]);
+ i++;
+ }
+
+ // Compose full header context
+ var fullHeader = string.Join(": ", headerStack.Select(h => h.Text));
+ if (contentLines.Count > 0)
+ sections.Add((fullHeader, contentLines));
+ }
+ return sections;
+ }
+
+ [GeneratedRegex(@"^Listing \d+\.\d+(:.*)?$")]
+ private static partial Regex ListingRegex();
+
+ [GeneratedRegex(@"^(#{1,6}) +(.+)$")]
+ private static partial Regex HeadingRegex();
+}
diff --git a/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj b/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj
new file mode 100644
index 00000000..f1432132
--- /dev/null
+++ b/EssentialCSharp.Chat.Tests/EssentialCSharp.Chat.Tests.csproj
@@ -0,0 +1,24 @@
+ο»Ώ
+
+
+ net9.0
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs
new file mode 100644
index 00000000..8aab8cb6
--- /dev/null
+++ b/EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs
@@ -0,0 +1,192 @@
+ο»Ώusing EssentialCSharp.Chat.Common.Services;
+using Moq;
+
+namespace EssentialCSharp.Chat.Tests;
+// TODO: Move to editorconfig later, just moving quick
+#pragma warning disable CA1707 // Identifiers should not contain underscores
+public class MarkdownChunkingServiceTests
+{
+ #region MarkdownContentToHeadersAndSection
+ [Fact]
+ public void MarkdownContentToHeadersAndSection_ParsesSampleMarkdown_CorrectlyCombinesHeadersAndExtractsContent()
+ {
+ string markdown = """
+### Beginner Topic
+#### What Is a Method?
+
+Syntactically, a **method** in C# is a named block of code introduced by a method declaration (e.g., `static void Main()`) and (usually) followed by zero or more statements within curly braces. Methods perform computations and/or actions. Like paragraphs in written languages, methods provide a means of structuring and organizing code so that it is more readable. More important, methods can be reused and called from multiple places and so avoid the need to duplicate code. The method declaration introduces the method and defines the method name along with the data passed to and from the method. In Listing 1.8, `Main()` followed by `{ ... }` is an example of a C# method.
+
+## Main Method
+
+The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`. When you execute the program by typing `dotnet run` on the terminal, the program starts with the Main method and begins executing the first statement, as identified in Listing 1.8.
+
+
+
+### Listing 1.8: Breaking Apart `HelloWorld`
+publicclass Program // BEGIN Class definition
+{
+publicstaticvoid Main() // Method declaration
+ { // BEGIN method implementation
+ Console.WriteLine( // This statement spans 2 lines
+"Hello, My name is Inigo Montoya");
+ } // END method implementation
+} // END class definition
+Although the Main method declaration can vary to some degree, `static` and the method name, `Main`, are always required for a program (see βAdvanced Topic: Declaration of the Main Methodβ).
+
+The **comments**, text that begins with `//` in Listing 1.8, are explained later in the chapter. They are included to identify the various constructs in the listing.
+
+### Advanced Topic
+#### Declaration of the Main Method
+
+C# requires that the Main method return either `void` or `int` and that it take either no parameters or a single array of strings. Listing 1.9 shows the full declaration of the Main method. The `args` parameter is an array of strings corresponding to the command-line arguments. The executable name is not included in the `args` array (unlike in C and C++). To retrieve the full command used to execute the program, including the program name, use `Environment.CommandLine`.
+""";
+
+ var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown);
+
+ Assert.Equal(3, sections.Count);
+ Assert.Contains(sections, s => s.Header == "Beginner Topic: What Is a Method?" && string.Join("\n", s.Content).Contains("Syntactically, a **method** in C# is a named block of code"));
+ Assert.Contains(sections, s => s.Header == "Main Method" && string.Join("\n", s.Content).Contains("The location where C# programs begin execution is the **Main method**, which begins with `static void Main()`")
+ && string.Join("\n", s.Content).Contains("publicclass Program"));
+ Assert.Contains(sections, s => s.Header == "Main Method: Advanced Topic: Declaration of the Main Method" && string.Join("\n", s.Content).Contains("C# requires that the Main method return either `void` or `int`"));
+ }
+
+ [Fact]
+ public void MarkdownContentToHeadersAndSection_AppendsCodeListingToPriorSection()
+ {
+ string markdown = """
+## Working with Variables
+
+Now that youβve been introduced to the most basic C# program, itβs time to declare a local variable. Once a variable is declared, you can assign it a value, replace that value with a new value, and use it in calculations, output, and so on. However, you cannot change the data type of the variable. In Listing 1.12, `string max` is a variable declaration.
+
+
+
+### Listing 1.12: Declaring and Assigning a Variable
+
+publicclass MiracleMax
+{
+publicstaticvoid Main()
+ {
+string max; // "string" identifies the data type
+// "max" is the variable
+ max = "Have fun storming the castle!";
+ Console.WriteLine(max);
+ }
+}
+
+### Beginner Topic
+#### Local Variables
+
+A **variable** is a name that refers to a value that can change over time. Local indicates that the programmer **declared** the variable within a method.
+
+To declare a variable is to define it, which you do by
+
+* Specifying the type of data which the variable will contain
+* Assigning it an identifier (name)
+""";
+
+ var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown);
+
+ Assert.Equal(2, sections.Count);
+ // The code listing should be appended to the Working with Variables section, not as its own section
+ var workingWithVariablesSection = sections.FirstOrDefault(s => s.Header == "Working with Variables");
+ Assert.True(!string.IsNullOrEmpty(workingWithVariablesSection.Header));
+ Assert.Contains("publicclass MiracleMax", string.Join("\n", workingWithVariablesSection.Content));
+ Assert.DoesNotContain(sections, s => s.Header == "Listing 1.12: Declaring and Assigning a Variable");
+ }
+
+ [Fact]
+ public void MarkdownContentToHeadersAndSection_KeepsPriorHeadersAppended()
+ {
+ string markdown = """
+### Beginner Topic
+#### What Is a Data Type?
+
+The type of data that a variable declaration specifies is called a **data type** (or object type). A data type, or simply **type**, is a classification of things that share similar characteristics and behavior. For example, animal is a type. It classifies all things (monkeys, warthogs, and platypuses) that have animal characteristics (multicellular, capacity for locomotion, and so on). Similarly, in programming languages, a type is a definition for several items endowed with similar qualities.
+
+## Declaring a Variable
+
+In Listing 1.12, `string max` is a variable declaration of a string type whose name is `max`. It is possible to declare multiple variables within the same statement by specifying the data type once and separating each identifier with a comma. Listing 1.13 demonstrates such a declaration.
+
+### Listing 1.13: Declaring Two Variables within One Statement
+string message1, message2;
+
+### Declaring another thing
+
+Because a multivariable declaration statement allows developers to provide the data type only once within a declaration, all variables will be of the same type.
+
+In C#, the name of the variable may begin with any letter or an underscore (`_`), followed by any number of letters, numbers, and/or underscores. By convention, however, local variable names are camelCased (the first letter in each word is capitalized, except for the first word) and do not include underscores.
+
+## Assigning a Variable
+
+After declaring a local variable, you must assign it a value before reading from it. One way to do this is to use the `=` **operator**, also known as the **simple assignment operator**. Operators are symbols used to identify the function the code is to perform. Listing 1.14 demonstrates how to use the assignment operator to designate the string values to which the variables `miracleMax` and `valerie` will point.
+
+### Listing 1.14: Changing the Value of a Variable
+publicclass StormingTheCastle
+{
+publicstaticvoid Main()
+ {
+string valerie;
+string miracleMax = "Have fun storming the castle!";
+
+ valerie = "Think it will work?";
+
+ Console.WriteLine(miracleMax);
+ Console.WriteLine(valerie);
+
+ miracleMax = "It would take a miracle.";
+ Console.WriteLine(miracleMax);
+ }
+}
+
+### Continued Learning
+From this listing, observe that it is possible to assign a variable as part of the variable declaration (as it was for `miracleMax`) or afterward in a separate statement (as with the variable `valerie`). The value assigned must always be on the right side of the declaration.
+""";
+
+ var sections = MarkdownChunkingService.MarkdownContentToHeadersAndSection(markdown);
+ Assert.Equal(5, sections.Count);
+
+ Assert.Contains(sections, s => s.Header == "Beginner Topic: What Is a Data Type?" && string.Join("\n", s.Content).Contains("The type of data that a variable declaration specifies is called a **data type**"));
+ Assert.Contains(sections, s => s.Header == "Declaring a Variable" && string.Join("\n", s.Content).Contains("In Listing 1.12, `string max` is a variable declaration"));
+ Assert.Contains(sections, s => s.Header == "Declaring a Variable: Declaring another thing" && string.Join("\n", s.Content).Contains("Because a multivariable declaration statement allows developers to provide the data type only once"));
+ Assert.Contains(sections, s => s.Header == "Assigning a Variable" && string.Join("\n", s.Content).Contains("After declaring a local variable, you must assign it a value before reading from it."));
+ Assert.Contains(sections, s => s.Header == "Assigning a Variable: Continued Learning" && string.Join("\n", s.Content).Contains("From this listing, observe that it is possible to assign a variable as part of the variable declaration"));
+ }
+ #endregion MarkdownContentToHeadersAndSection
+
+ #region ProcessSingleMarkdownFile
+ [Fact]
+ public void ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders()
+ {
+ // Arrange
+ var logger = new Mock>().Object;
+ var service = new MarkdownChunkingService(logger);
+ string[] fileContent = new[]
+ {
+ "## Section 1",
+ "This is the first section.",
+ "",
+ "### Listing 1.1: Example Listing",
+ "Console.WriteLine(\"Hello World\");",
+ "",
+ "## Section 2",
+ "This is the second section."
+ };
+ string fileName = "TestFile.md";
+ string filePath = "/path/to/TestFile.md";
+
+ // Act
+ var result = service.ProcessSingleMarkdownFile(fileContent, fileName, filePath);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(fileName, result.FileName);
+ Assert.Equal(filePath, result.FilePath);
+ Assert.Contains("This is the first section.", string.Join("\n", result.Chunks));
+ Assert.Contains("Console.WriteLine(\"Hello World\");", string.Join("\n", result.Chunks));
+ Assert.Contains("This is the second section.", string.Join("\n", result.Chunks));
+ Assert.Contains(result.Chunks, c => c.Contains("This is the second section."));
+ }
+ #endregion ProcessSingleMarkdownFile
+}
+
+#pragma warning restore CA1707 // Identifiers should not contain underscores
diff --git a/EssentialCSharp.Chat/EssentialCSharp.Chat.csproj b/EssentialCSharp.Chat/EssentialCSharp.Chat.csproj
new file mode 100644
index 00000000..bf161a97
--- /dev/null
+++ b/EssentialCSharp.Chat/EssentialCSharp.Chat.csproj
@@ -0,0 +1,37 @@
+ο»Ώ
+
+
+ Exe
+ net9.0
+ 0.0.1
+
+
+
+
+
+ EssentialCSharp.Chat
+ true
+ essentialcsharpchat
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/EssentialCSharp.Chat/Program.cs b/EssentialCSharp.Chat/Program.cs
new file mode 100644
index 00000000..7b3f89f1
--- /dev/null
+++ b/EssentialCSharp.Chat/Program.cs
@@ -0,0 +1,419 @@
+using System.CommandLine;
+using System.Text.Json;
+using Azure.Identity;
+using EssentialCSharp.Chat.Common.Extensions;
+using EssentialCSharp.Chat.Common.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.SemanticKernel;
+
+namespace EssentialCSharp.Chat;
+
+public class Program
+{
+ private static readonly JsonSerializerOptions _JsonOptions = new() { WriteIndented = true };
+
+ static int Main(string[] args)
+ {
+ Option directoryOption = new("--directory")
+ {
+ Description = "Directory containing markdown files.",
+ Required = true
+ };
+ Option filePatternOption = new("--file-pattern")
+ {
+ Description = "File pattern to match (e.g. *.md)",
+ DefaultValueFactory = _ => "*.md"
+ };
+ Option outputDirectoryOption = new("--output-directory")
+ {
+ Description = "Directory to write chunked output files. If not provided, output is written to console.",
+ };
+
+ RootCommand rootCommand = new("EssentialCSharp.Chat Utilities");
+
+ var chunkMarkdownCommand = new Command("chunk-markdown", "Chunk markdown files in a directory.")
+ {
+ directoryOption,
+ filePatternOption,
+ outputDirectoryOption
+ };
+
+ var buildVectorDbCommand = new Command("build-vector-db", "Build a vector database from markdown chunks.")
+ {
+ directoryOption,
+ filePatternOption,
+ };
+
+ var chatCommand = new Command("chat", "Start an interactive AI chat session.")
+ {
+ new Option("--stream"),
+ new Option("--web-search"),
+ new Option("--contextual-search"),
+ new Option("--system-prompt")
+ };
+
+ buildVectorDbCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) =>
+ {
+ var config = CreateConfiguration();
+
+ var builder = Kernel.CreateBuilder();
+ builder.Services.Configure(config.GetRequiredSection("AIOptions"));
+
+ // Use shared extension to register Azure OpenAI services with configuration
+ builder.Services.AddAzureOpenAIServices(config);
+
+ builder.Services.AddLogging(loggingBuilder =>
+ {
+ loggingBuilder.AddSimpleConsole(options =>
+ {
+ options.TimestampFormat = "HH:mm:ss ";
+ options.SingleLine = true;
+ });
+ });
+
+ // Build the kernel and get the data uploader.
+ var kernel = builder.Build();
+ var directory = parseResult.GetValue(directoryOption);
+ var filePattern = parseResult.GetValue(filePatternOption) ?? "*.md";
+ var markdownService = kernel.GetRequiredService();
+ if (directory is null)
+ {
+ Console.Error.WriteLine("Error: Directory is required.");
+ return;
+ }
+ var results = await markdownService.ProcessMarkdownFilesAsync(directory, filePattern);
+ // Convert results to BookContentChunks
+ var bookContentChunks = results.SelectMany(result => result.ToBookContentChunks()).ToList();
+ // Generate embeddings and upload to vector store
+ var embeddingService = kernel.GetRequiredService();
+ await embeddingService.GenerateBookContentEmbeddingsAndUploadToVectorStore(bookContentChunks, cancellationToken, "markdown_chunks");
+ Console.WriteLine($"Successfully processed {bookContentChunks.Count} chunks.");
+ });
+
+ chatCommand.SetAction(async (ParseResult parseResult, CancellationToken cancellationToken) =>
+ {
+ var config = CreateConfiguration();
+
+ // https://learn.microsoft.com/api/mcp
+
+ //SseClientTransport microsoftLearnMcp = new SseClientTransport(
+ // new SseClientTransportOptions
+ // {
+ // Name = "Microsoft Learn MCP",
+ // Endpoint = new Uri("https://learn.microsoft.com/api/mcp"),
+ // });
+
+ //IMcpClient mcpClient = await McpClientFactory.CreateAsync(clientTransport: microsoftLearnMcp, cancellationToken: cancellationToken);
+
+ var enableStreaming = parseResult.GetValue("--stream");
+ var customSystemPrompt = parseResult.GetValue("--system-prompt");
+
+
+ AIOptions aiOptions = config.GetRequiredSection("AIOptions").Get() ?? throw new InvalidOperationException(
+ "AIOptions section is missing or not configured correctly in appsettings.json or environment variables.");
+
+ // Create service collection and register dependencies
+ var services = new ServiceCollection();
+ services.Configure(config.GetRequiredSection("AIOptions"));
+ services.AddLogging(builder => builder.AddSimpleConsole(options =>
+ {
+ options.TimestampFormat = "HH:mm:ss ";
+ options.SingleLine = true;
+ }));
+
+ // Use shared extension to register Azure OpenAI services with configuration
+ services.AddAzureOpenAIServices(config);
+
+ var serviceProvider = services.BuildServiceProvider();
+ var aiChatService = serviceProvider.GetRequiredService();
+
+ Console.WriteLine("π€ AI Chat Session Started!");
+ Console.WriteLine("Features enabled:");
+ Console.WriteLine($" β’ Streaming: {(enableStreaming ? "β " : "β")}");
+ if (!string.IsNullOrEmpty(customSystemPrompt))
+ Console.WriteLine($" β’ Custom System Prompt: {customSystemPrompt}");
+ Console.WriteLine();
+ Console.WriteLine("Commands:");
+ Console.WriteLine(" β’ 'exit' or 'quit' - End the chat session");
+ Console.WriteLine(" β’ 'clear' - Start a new conversation context");
+ Console.WriteLine(" β’ 'help' - Show this help message");
+ Console.WriteLine(" β’ 'history' - Show conversation history");
+ Console.WriteLine(" β’ Any other text - Chat with the AI");
+ Console.WriteLine("=====================================");
+
+ // Track conversation context with response IDs
+ string? previousResponseId = null;
+ var conversationHistory = new List<(string Role, string Content)>();
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ Console.WriteLine();
+ Console.Write("π€ You: ");
+ var userInput = Console.ReadLine();
+
+ if (string.IsNullOrWhiteSpace(userInput))
+ continue;
+
+ userInput = userInput.Trim();
+
+ if (userInput.Equals("exit", StringComparison.OrdinalIgnoreCase) ||
+ userInput.Equals("quit", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine("Goodbye! π");
+ break;
+ }
+
+ if (userInput.Equals("clear", StringComparison.OrdinalIgnoreCase))
+ {
+ // Reset conversation context when PreviousResponseId is implemented
+ previousResponseId = null;
+ conversationHistory.Clear();
+ Console.WriteLine("π§Ή Conversation context cleared. Starting fresh!");
+ continue;
+ }
+
+ if (userInput.Equals("help", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine();
+ Console.WriteLine("Commands:");
+ Console.WriteLine(" β’ 'exit' or 'quit' - End the chat session");
+ Console.WriteLine(" β’ 'clear' - Start a new conversation context");
+ Console.WriteLine(" β’ 'help' - Show this help message");
+ Console.WriteLine(" β’ 'history' - Show conversation history");
+ Console.WriteLine(" β’ Any other text - Chat with the AI");
+ continue;
+ }
+
+ if (userInput.Equals("history", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine();
+ Console.WriteLine("π Conversation History:");
+ if (conversationHistory.Count == 0)
+ {
+ Console.WriteLine(" No conversation history yet.");
+ }
+ else
+ {
+ for (int i = 0; i < conversationHistory.Count; i++)
+ {
+ var (role, content) = conversationHistory[i];
+ var emoji = role == "User" ? "π€" : "π€";
+ Console.WriteLine($" {i + 1}. {emoji} {role}: {content}");
+ }
+ }
+ continue;
+ }
+
+ conversationHistory.Add(("User", userInput));
+
+ try
+ {
+ Console.Write("π€ AI: ");
+
+ if (enableStreaming)
+ {
+ // Use streaming with optional tools and conversation context
+ var fullResponse = new System.Text.StringBuilder();
+
+ await foreach (var (text, responseId) in aiChatService.GetChatCompletionStream(
+ prompt: userInput/*, mcpClient: mcpClient*/, previousResponseId: previousResponseId, systemPrompt: customSystemPrompt, cancellationToken: cancellationToken))
+ {
+ if (!string.IsNullOrEmpty(text))
+ {
+ Console.Write(text);
+ fullResponse.Append(text);
+ }
+ if (!string.IsNullOrEmpty(responseId))
+ {
+ previousResponseId = responseId; // Update for next turn
+ }
+ }
+ Console.WriteLine();
+
+ conversationHistory.Add(("Assistant", fullResponse.ToString()));
+ }
+ else
+ {
+ // Non-streaming response with optional tools and conversation context
+ var (response, responseId) = await aiChatService.GetChatCompletion(
+ prompt: userInput, previousResponseId: previousResponseId, systemPrompt: customSystemPrompt, cancellationToken: cancellationToken);
+
+ Console.WriteLine(response);
+ conversationHistory.Add(("Assistant", response));
+
+ if (!string.IsNullOrEmpty(responseId))
+ {
+ previousResponseId = responseId;
+ }
+ }
+
+ Console.WriteLine();
+ }
+ catch (OperationCanceledException)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Operation cancelled. Goodbye! π");
+ break;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"β Error: {ex.Message}");
+ if (ex.InnerException != null)
+ {
+ Console.WriteLine($" Details: {ex.InnerException.Message}");
+ }
+ }
+ }
+ });
+
+ chunkMarkdownCommand.SetAction(async parseResult =>
+ {
+ var directory = parseResult.GetValue(directoryOption);
+ var filePattern = parseResult.GetValue(filePatternOption) ?? "*.md";
+ var outputDirectory = parseResult.GetValue(outputDirectoryOption);
+
+ using var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole());
+ var logger = loggerFactory.CreateLogger();
+ var service = new MarkdownChunkingService(logger);
+ try
+ {
+ if (directory is null)
+ {
+ Console.Error.WriteLine("Error: Directory is required.");
+ return;
+ }
+ var results = await service.ProcessMarkdownFilesAsync(directory, filePattern);
+
+ int maxChunkLength = 0;
+ int minChunkLength = 0;
+
+ void WriteChunkingResult(FileChunkingResult result, TextWriter writer)
+ {
+ // lets build up some stats over the chunking
+ var chunkAverage = result.Chunks.Average(chunk => chunk.Length);
+ var chunkMedian = result.Chunks.OrderBy(chunk => chunk.Length).ElementAt(result.Chunks.Count / 2).Length;
+ var chunkMax = result.Chunks.Max(chunk => chunk.Length);
+ var chunkMin = result.Chunks.Min(chunk => chunk.Length);
+ var chunkTotal = result.Chunks.Sum(chunk => chunk.Length);
+ var chunkStandardDeviation = Math.Sqrt(result.Chunks.Average(chunk => Math.Pow(chunk.Length - chunkAverage, 2)));
+ var numberOfOutliers = result.Chunks.Count(chunk => chunk.Length > chunkAverage + chunkStandardDeviation);
+
+ if (chunkMax > maxChunkLength) maxChunkLength = chunkMax;
+ if (chunkMin < minChunkLength || minChunkLength == 0) minChunkLength = chunkMin;
+
+ writer.WriteLine($"File: {result.FileName}");
+ writer.WriteLine($"Number of Chunks: {result.ChunkCount}");
+ writer.WriteLine($"Average Chunk Length: {chunkAverage}");
+ writer.WriteLine($"Median Chunk Length: {chunkMedian}");
+ writer.WriteLine($"Max Chunk Length: {chunkMax}");
+ writer.WriteLine($"Min Chunk Length: {chunkMin}");
+ writer.WriteLine($"Total Chunk Characters: {chunkTotal}");
+ writer.WriteLine($"Standard Deviation: {chunkStandardDeviation}");
+ writer.WriteLine($"Number of Outliers: {numberOfOutliers}");
+ writer.WriteLine($"Original Character Count: {result.OriginalCharCount}");
+ writer.WriteLine($"New Character Count: {result.TotalChunkCharacters}");
+ foreach (var chunk in result.Chunks)
+ {
+ writer.WriteLine();
+ writer.WriteLine(chunk);
+ }
+ }
+
+ if (outputDirectory != null)
+ {
+ if (!outputDirectory.Exists)
+ outputDirectory.Create();
+ foreach (var result in results)
+ {
+ var outputFile = Path.Combine(outputDirectory.FullName, Path.GetFileNameWithoutExtension(result.FileName) + ".chunks.txt");
+ using var writer = new StreamWriter(outputFile, false);
+ WriteChunkingResult(result, writer);
+ Console.WriteLine($"Wrote: {outputFile}");
+ }
+ }
+ else
+ {
+ foreach (var result in results)
+ {
+ WriteChunkingResult(result, Console.Out);
+ }
+ }
+ Console.WriteLine($"Max Chunk Length: {maxChunkLength}");
+ Console.WriteLine($"Min Chunk Length: {minChunkLength}");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Error: {ex.Message}");
+ return;
+ }
+ });
+ rootCommand.Subcommands.Add(chunkMarkdownCommand);
+ rootCommand.Subcommands.Add(buildVectorDbCommand);
+ rootCommand.Subcommands.Add(chatCommand);
+
+ return rootCommand.Parse(args).Invoke();
+ }
+
+ ///
+ /// Creates and configures the IConfiguration used by multiple commands.
+ /// Supports Azure Key Vault integration for secure secret management.
+ ///
+ /// The configured IConfigurationRoot
+ ///
+ /// Configuration precedence (highest to lowest):
+ /// 1. Environment Variables
+ /// 2. Azure Key Vault (if configured)
+ /// 3. User Secrets (development only)
+ /// 4. appsettings.json
+ ///
+ /// To enable Key Vault, set the "KeyVaultName" configuration value in appsettings.json or user secrets:
+ /// {
+ /// "KeyVaultName": "your-keyvault-name"
+ /// }
+ ///
+ /// The application will use DefaultAzureCredential for authentication, which supports:
+ /// - Managed Identity (in Azure)
+ /// - Azure CLI (local development)
+ /// - Visual Studio (local development)
+ /// - Environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
+ ///
+ private static IConfigurationRoot CreateConfiguration()
+ {
+ var configBuilder = new ConfigurationBuilder()
+ .SetBasePath(IntelliTect.Multitool.RepositoryPaths.GetDefaultRepoRoot())
+ .AddJsonFile("EssentialCSharp.Web/appsettings.json", optional: false, reloadOnChange: true)
+ .AddJsonFile($"EssentialCSharp.Web/appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true, reloadOnChange: true)
+ .AddUserSecrets(optional: true)
+ .AddEnvironmentVariables();
+
+ // Build a temporary configuration to check for Key Vault settings
+ var tempConfig = configBuilder.Build();
+ var keyVaultName = tempConfig["KeyVaultName"];
+
+ // If Key Vault is configured, add it to the configuration pipeline
+ if (!string.IsNullOrEmpty(keyVaultName))
+ {
+ try
+ {
+ var keyVaultUri = new Uri($"https://{keyVaultName}.vault.azure.net/");
+
+ // Use DefaultAzureCredential which works both locally and in Azure
+ var credential = new DefaultAzureCredential();
+
+ configBuilder.AddAzureKeyVault(keyVaultUri, credential);
+
+ Console.WriteLine($"β Connected to Azure Key Vault: {keyVaultName}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"β οΈ Warning: Could not connect to Azure Key Vault '{keyVaultName}': {ex.Message}");
+ Console.WriteLine(" Continuing with other configuration sources...");
+ }
+ }
+
+ return configBuilder.Build();
+ }
+}
diff --git a/EssentialCSharp.Chat/Properties/launchSettings.json b/EssentialCSharp.Chat/Properties/launchSettings.json
new file mode 100644
index 00000000..cc4de81e
--- /dev/null
+++ b/EssentialCSharp.Chat/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "EssentialCSharp.Chat": {
+ "commandName": "Project",
+ "commandLineArgs": "chat --stream --web-search"
+ }
+ }
+}
\ No newline at end of file
diff --git a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
index 97dcb54c..cde07e16 100644
--- a/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
+++ b/EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
@@ -1,9 +1,10 @@
-
+ο»Ώ
- net7.0
+ net9.0false
+ false
@@ -11,19 +12,25 @@
-
-
-
-
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitiveall
-
+ runtime; build; native; contentfiles; analyzers; buildtransitiveall
+
+
+
+
diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs
index 14d2ff84..abdf499d 100644
--- a/EssentialCSharp.Web.Tests/FunctionalTests.cs
+++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs
@@ -1,4 +1,4 @@
-ο»Ώusing System.Net;
+using System.Net;
namespace EssentialCSharp.Web.Tests;
@@ -8,6 +8,8 @@ public class FunctionalTests
[InlineData("/")]
[InlineData("/hello-world")]
[InlineData("/hello-world#hello-world")]
+ [InlineData("/guidelines")]
+ [InlineData("/healthz")]
public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl)
{
using WebApplicationFactory factory = new();
@@ -17,4 +19,40 @@ public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
+
+ [Theory]
+ [InlineData("/guidelines?rid=test-referral-id")]
+ [InlineData("/about?rid=abc123")]
+ [InlineData("/hello-world?rid=user-referral")]
+ [InlineData("/guidelines?rid=")]
+ [InlineData("/about?rid= ")]
+ [InlineData("/guidelines?foo=bar")]
+ [InlineData("/about?someOtherParam=value")]
+ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl)
+ {
+ using WebApplicationFactory factory = new();
+
+ HttpClient client = factory.CreateClient();
+ using HttpResponseMessage response = await client.GetAsync(relativeUrl);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ // Ensure the response has content (not blank)
+ string content = await response.Content.ReadAsStringAsync();
+ Assert.NotEmpty(content);
+
+ // Verify it's actually HTML content, not just whitespace
+ Assert.Contains("
+{
+ [Fact]
+ public async Task CaptchaService_Verify_Success()
+ {
+ ICaptchaService captchaService = serviceProvider.ServiceProvider.GetRequiredService();
+
+ // From https://docs.hcaptcha.com/#integration-testing-test-keys
+ string hCaptchaSecret = "0x0000000000000000000000000000000000000000";
+ string hCaptchaToken = "10000000-aaaa-bbbb-cccc-000000000001";
+ string hCaptchaSiteKey = "10000000-ffff-ffff-ffff-000000000001";
+ HCaptchaResult? response = await captchaService.VerifyAsync(hCaptchaSecret, hCaptchaToken, hCaptchaSiteKey);
+
+ Assert.NotNull(response);
+ Assert.True(response.Success);
+ }
+}
+
+public class CaptchaServiceProvider
+{
+ public ServiceProvider ServiceProvider { get; } = CreateServiceProvider();
+ public static ServiceProvider CreateServiceProvider()
+ {
+ IServiceCollection services = new ServiceCollection();
+
+ IConfigurationRoot configuration = new ConfigurationBuilder()
+ .SetBasePath(IntelliTect.Multitool.RepositoryPaths.GetDefaultRepoRoot())
+ .AddJsonFile($"{nameof(EssentialCSharp)}.{nameof(Web)}/appsettings.json")
+ .Build();
+ services.AddCaptchaService(configuration.GetSection(CaptchaOptions.CaptchaSender));
+ // Add other necessary services here
+
+ return services.BuildServiceProvider();
+ }
+}
diff --git a/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs
new file mode 100644
index 00000000..9280e3fd
--- /dev/null
+++ b/EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs
@@ -0,0 +1,35 @@
+using EssentialCSharp.Web.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EssentialCSharp.Web.Tests;
+
+public class RouteConfigurationServiceTests : IClassFixture
+{
+ private readonly WebApplicationFactory _Factory;
+
+ public RouteConfigurationServiceTests(WebApplicationFactory factory)
+ {
+ _Factory = factory;
+ }
+
+ [Fact]
+ public void GetStaticRoutes_ShouldReturnExpectedRoutes()
+ {
+ // Act
+ var routes = _Factory.InServiceScope(serviceProvider =>
+ {
+ var routeConfigurationService = serviceProvider.GetRequiredService();
+ return routeConfigurationService.GetStaticRoutes().ToList();
+ });
+
+ // Assert
+ Assert.NotEmpty(routes);
+
+ // Check for expected routes from the HomeController
+ Assert.Contains("home", routes);
+ Assert.Contains("about", routes);
+ Assert.Contains("guidelines", routes);
+ Assert.Contains("announcements", routes);
+ Assert.Contains("termsofservice", routes);
+ }
+}
diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs
new file mode 100644
index 00000000..cf4c2c34
--- /dev/null
+++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs
@@ -0,0 +1,152 @@
+ο»Ώusing EssentialCSharp.Web.Extensions;
+
+namespace EssentialCSharp.Web.Tests;
+
+public class SiteMappingTests
+{
+ static SiteMapping HelloWorldSiteMapping => new(
+ keys: ["hello-world"],
+ primaryKey: "hello-world",
+ pagePath:
+ [
+ "Chapters",
+ "01",
+ "Pages",
+ "01.html"
+ ],
+ chapterNumber: 1,
+ pageNumber: 1,
+ orderOnPage: 1,
+ chapterTitle: "Introducing C#",
+ rawHeading: "Introduction",
+ anchorId: "hello-world",
+ indentLevel: 0
+ );
+
+ static SiteMapping CSyntaxFundamentalsSiteMapping => new(
+ keys: ["c-syntax-fundamentals"],
+ primaryKey: "c-syntax-fundamentals",
+ pagePath:
+ [
+ "Chapters",
+ "01",
+ "Pages",
+ "02.html"
+ ],
+ chapterNumber: 1,
+ pageNumber: 2,
+ orderOnPage: 1,
+ chapterTitle: "Introducing C#",
+ rawHeading: "C# Syntax Fundamentals",
+ anchorId: "c-syntax-fundamentals",
+ indentLevel: 2
+ );
+
+ public static List GetSiteMap()
+ {
+ return
+ [
+ HelloWorldSiteMapping,
+ CSyntaxFundamentalsSiteMapping
+ ];
+ }
+
+ [Fact]
+ public void FindHelloWorldWithAnchorSlugReturnsCorrectSiteMap()
+ {
+ SiteMapping? foundSiteMap = GetSiteMap().Find("hello-world#hello-world");
+ Assert.NotNull(foundSiteMap);
+ Assert.Equivalent(HelloWorldSiteMapping, foundSiteMap);
+ }
+
+ [Fact]
+ public void FindCSyntaxFundamentalsWithSpacesReturnsCorrectSiteMap()
+ {
+ SiteMapping? foundSiteMap = GetSiteMap().Find("C# Syntax Fundamentals");
+ Assert.NotNull(foundSiteMap);
+ Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap);
+ }
+
+ [Fact]
+ public void FindCSyntaxFundamentalsWithSpacesAndAnchorReturnsCorrectSiteMap()
+ {
+ SiteMapping? foundSiteMap = GetSiteMap().Find("C# Syntax Fundamentals#hello-world");
+ Assert.NotNull(foundSiteMap);
+ Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap);
+ }
+
+ [Fact]
+ public void FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap()
+ {
+ SiteMapping? foundSiteMap = GetSiteMap().Find("c-syntax-fundamentals#hello-world");
+ Assert.NotNull(foundSiteMap);
+ Assert.Equivalent(CSyntaxFundamentalsSiteMapping, foundSiteMap);
+ }
+
+ [Fact]
+ public void FindPercentComplete_KeyIsNull_ReturnsNull()
+ {
+ // Arrange
+
+ // Act
+ string? percent = GetSiteMap().FindPercentComplete(null!);
+
+ // Assert
+ Assert.Null(percent);
+ }
+
+ [Theory]
+ [InlineData(" ")]
+ [InlineData("")]
+ public void FindPercentComplete_KeyIsWhiteSpace_ThrowsArgumentException(string? key)
+ {
+ // Arrange
+
+ // Act
+
+ // Assert
+ Assert.Throws(() =>
+ {
+ GetSiteMap().FindPercentComplete(key);
+ });
+ }
+
+ [Theory]
+ [InlineData("hello-world", "50.00")]
+ [InlineData("c-syntax-fundamentals", "100.00")]
+ public void FindPercentComplete_ValidKey_Success(string? key, string result)
+ {
+ // Arrange
+
+ // Act
+ string? percent = GetSiteMap().FindPercentComplete(key);
+
+ // Assert
+ Assert.Equal(result, percent);
+ }
+
+ [Fact]
+ public void FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent()
+ {
+ // Arrange
+ IList siteMappings = new List();
+
+ // Act
+ string? percent = siteMappings.FindPercentComplete("test");
+
+ // Assert
+ Assert.Equal("0.00", percent);
+ }
+
+ [Fact]
+ public void FindPercentComplete_KeyNotFound_ReturnsZeroPercent()
+ {
+ // Arrange
+
+ // Act
+ string? percent = GetSiteMap().FindPercentComplete("non-existent-key");
+
+ // Assert
+ Assert.Equal("0.00", percent);
+ }
+}
diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs
new file mode 100644
index 00000000..be8edc90
--- /dev/null
+++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs
@@ -0,0 +1,249 @@
+using System.Globalization;
+using DotnetSitemapGenerator;
+using EssentialCSharp.Web.Helpers;
+using EssentialCSharp.Web.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace EssentialCSharp.Web.Tests;
+
+public class SitemapXmlHelpersTests : IClassFixture
+{
+ private readonly WebApplicationFactory _Factory;
+
+ public SitemapXmlHelpersTests(WebApplicationFactory factory)
+ {
+ _Factory = factory;
+ }
+
+ [Fact]
+ public void EnsureSitemapHealthy_WithValidSiteMappings_DoesNotThrow()
+ {
+ // Arrange
+ var siteMappings = new List
+ {
+ CreateSiteMapping(1, 1, true),
+ CreateSiteMapping(1, 2, true),
+ CreateSiteMapping(2, 1, true)
+ };
+
+ // Act & Assert
+ var exception = Record.Exception(() => SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void EnsureSitemapHealthy_WithMultipleCanonicalLinksForSamePage_ThrowsException()
+ {
+ // Arrange - Two mappings for the same chapter/page both marked as canonical
+ var siteMappings = new List
+ {
+ CreateSiteMapping(1, 1, true),
+ CreateSiteMapping(1, 1, true) // Same chapter/page, also canonical
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings));
+
+ Assert.Contains("Chapter 1, Page 1", exception.Message);
+ Assert.Contains("more than one canonical link", exception.Message);
+ }
+
+ [Fact]
+ public void EnsureSitemapHealthy_WithNoCanonicalLinksForPage_ThrowsException()
+ {
+ // Arrange - No mappings marked as canonical for this page
+ var siteMappings = new List
+ {
+ CreateSiteMapping(1, 1, false),
+ CreateSiteMapping(1, 1, false) // Same chapter/page, neither canonical
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() =>
+ SitemapXmlHelpers.EnsureSitemapHealthy(siteMappings));
+
+ Assert.Contains("Chapter 1, Page 1", exception.Message);
+ }
+
+ [Fact]
+ public void GenerateSitemapXml_DoesNotIncludeIdentityRoutes()
+ {
+ // Arrange
+ var tempDir = new DirectoryInfo(Path.GetTempPath());
+ var siteMappings = new List { CreateSiteMapping(1, 1, true) };
+ var baseUrl = "https://test.example.com/";
+
+ // Act & Assert
+ var routeConfigurationService = _Factory.Services.GetRequiredService();
+ SitemapXmlHelpers.GenerateSitemapXml(
+ tempDir,
+ siteMappings,
+ routeConfigurationService,
+ baseUrl,
+ out var nodes);
+
+ var allUrls = nodes.Select(n => n.Url).ToList();
+
+ // Verify no Identity routes are included
+ Assert.DoesNotContain(allUrls, url => url.Contains("Identity", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(allUrls, url => url.Contains("Account", StringComparison.OrdinalIgnoreCase));
+
+ // But verify that expected routes are included
+ Assert.Contains(allUrls, url => url.Contains("/home", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(allUrls, url => url.Contains("/about", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void GenerateSitemapXml_IncludesBaseUrl()
+ {
+ // Arrange
+ var tempDir = new DirectoryInfo(Path.GetTempPath());
+ var siteMappings = new List();
+ var baseUrl = "https://test.example.com/";
+
+ // Act & Assert
+ var routeConfigurationService = _Factory.Services.GetRequiredService();
+ SitemapXmlHelpers.GenerateSitemapXml(
+ tempDir,
+ siteMappings,
+ routeConfigurationService,
+ baseUrl,
+ out var nodes);
+
+ Assert.Contains(nodes, node => node.Url == baseUrl);
+
+ // Verify the root URL has highest priority
+ var rootNode = nodes.First(node => node.Url == baseUrl);
+ Assert.Equal(1.0M, rootNode.Priority);
+ Assert.Equal(ChangeFrequency.Daily, rootNode.ChangeFrequency);
+ }
+
+ [Fact]
+ public void GenerateSitemapXml_IncludesSiteMappingsMarkedForXml()
+ {
+ // Arrange
+ var tempDir = new DirectoryInfo(Path.GetTempPath());
+ var baseUrl = "https://test.example.com/";
+
+ var siteMappings = new List
+ {
+ CreateSiteMapping(1, 1, true, "test-page-1"),
+ CreateSiteMapping(1, 2, false, "test-page-2"), // Not included in XML
+ CreateSiteMapping(2, 1, true, "test-page-3")
+ };
+
+ // Act & Assert
+ var routeConfigurationService = _Factory.Services.GetRequiredService();
+ SitemapXmlHelpers.GenerateSitemapXml(
+ tempDir,
+ siteMappings,
+ routeConfigurationService,
+ baseUrl,
+ out var nodes);
+
+ var allUrls = nodes.Select(n => n.Url).ToList();
+
+ Assert.Contains(allUrls, url => url.Contains("test-page-1"));
+ Assert.DoesNotContain(allUrls, url => url.Contains("test-page-2")); // Not marked for XML
+ Assert.Contains(allUrls, url => url.Contains("test-page-3"));
+ }
+
+ [Fact]
+ public void GenerateSitemapXml_DoesNotIncludeIndexRoutes()
+ {
+ // Arrange
+ var tempDir = new DirectoryInfo(Path.GetTempPath());
+ var siteMappings = new List();
+ var baseUrl = "https://test.example.com/";
+
+ // Act & Assert
+ var routeConfigurationService = _Factory.Services.GetRequiredService();
+ SitemapXmlHelpers.GenerateSitemapXml(
+ tempDir,
+ siteMappings,
+ routeConfigurationService,
+ baseUrl,
+ out var nodes);
+
+ var allUrls = nodes.Select(n => n.Url).ToList();
+
+ // Should not include Index action routes (they're the default)
+ Assert.DoesNotContain(allUrls, url => url.Contains("/Index", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void GenerateSitemapXml_DoesNotIncludeErrorRoutes()
+ {
+ // Arrange
+ var tempDir = new DirectoryInfo(Path.GetTempPath());
+ var siteMappings = new List();
+ var baseUrl = "https://test.example.com/";
+
+ // Act & Assert
+ var routeConfigurationService = _Factory.Services.GetRequiredService();
+ SitemapXmlHelpers.GenerateSitemapXml(
+ tempDir,
+ siteMappings,
+ routeConfigurationService,
+ baseUrl,
+ out var nodes);
+
+ var allUrls = nodes.Select(n => n.Url).ToList();
+
+ // Should not include Error action routes
+ Assert.DoesNotContain(allUrls, url => url.Contains("/Error", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void GenerateSitemapXml_UsesLastModifiedDateFromSiteMapping()
+ {
+ // Arrange
+ var tempDir = new DirectoryInfo(Path.GetTempPath());
+ var baseUrl = "https://test.example.com/";
+ var specificLastModified = new DateTime(2023, 5, 15, 10, 30, 0, DateTimeKind.Utc);
+
+ var siteMappings = new List
+ {
+ CreateSiteMapping(1, 1, true, "test-page-1", specificLastModified)
+ };
+
+ // Act
+ var routeConfigurationService = _Factory.Services.GetRequiredService();
+ SitemapXmlHelpers.GenerateSitemapXml(
+ tempDir,
+ siteMappings,
+ routeConfigurationService,
+ baseUrl,
+ out var nodes);
+
+ // Assert
+ var siteMappingNode = nodes.First(node => node.Url.Contains("test-page-1"));
+ Assert.Equal(specificLastModified, siteMappingNode.LastModificationDate);
+ }
+
+ private static SiteMapping CreateSiteMapping(
+ int chapterNumber,
+ int pageNumber,
+ bool includeInSitemapXml,
+ string key = "test-key",
+ DateTime? lastModified = null)
+ {
+ return new SiteMapping(
+ keys: [key],
+ primaryKey: key,
+ pagePath: ["Chapters", chapterNumber.ToString("00", CultureInfo.InvariantCulture), "Pages", $"{pageNumber:00}.html"],
+ chapterNumber: chapterNumber,
+ pageNumber: pageNumber,
+ orderOnPage: 0,
+ chapterTitle: $"Chapter {chapterNumber}",
+ rawHeading: "Test Heading",
+ anchorId: key,
+ indentLevel: 1,
+ contentHash: "TestHash123",
+ includeInSitemapXml: includeInSitemapXml,
+ lastModified: lastModified
+ );
+ }
+}
diff --git a/EssentialCSharp.Web.Tests/Web.Tests.cs b/EssentialCSharp.Web.Tests/Web.Tests.cs
deleted file mode 100644
index 4713e2a9..00000000
--- a/EssentialCSharp.Web.Tests/Web.Tests.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-ο»Ώnamespace EssentialCSharp.Web.Tests;
-
-public class SiteMappingTests
-{
- static SiteMapping HelloWorldSiteMapping { get; } = new(Key: "hello-world",
- PagePath: new string[]
- {
- "Chapters",
- "01",
- "Pages",
- "01.html"
- },
- ChapterNumber: 1,
- PageNumber: 1,
- ChapterTitle: "Introducing C#",
- RawHeading: "Introduction",
- AnchorId: "hello-world",
- IndentLevel: 0);
- static SiteMapping CSyntaxFundamentalsSiteMapping { get; } = new(Key: "c-syntax-fundamentals",
- PagePath: new string[]
- {
- "Chapters",
- "01",
- "Pages",
- "02.html"
- },
- ChapterNumber: 1,
- PageNumber: 2,
- ChapterTitle: "Introducing C#",
- RawHeading: "C# Syntax Fundamentals",
- AnchorId: "c-syntax-fundamentals",
- IndentLevel: 2);
- public static List GetSiteMap()
- {
- return new List()
- {
- HelloWorldSiteMapping,
- CSyntaxFundamentalsSiteMapping
- };
- }
-
- [Fact]
- public void FindHelloWorldWithAnchorSlugReturnsCorrectSiteMap()
- {
- SiteMapping? foundSiteMap = SiteMapping.Find("hello-world#hello-world", GetSiteMap());
- Assert.NotNull(foundSiteMap);
- Assert.Equal(HelloWorldSiteMapping, foundSiteMap!);
- }
- [Fact]
- public void FindCSyntaxFundamentalsWithSpacesReturnsCorrectSiteMap()
- {
- SiteMapping? foundSiteMap = SiteMapping.Find("C# Syntax Fundamentals", GetSiteMap());
- Assert.NotNull(foundSiteMap);
- Assert.Equal(CSyntaxFundamentalsSiteMapping, foundSiteMap!);
- }
- [Fact]
- public void FindCSyntaxFundamentalsWithSpacesAndAnchorReturnsCorrectSiteMap()
- {
- SiteMapping? foundSiteMap = SiteMapping.Find("C# Syntax Fundamentals#hello-world", GetSiteMap());
- Assert.NotNull(foundSiteMap);
- Assert.Equal(CSyntaxFundamentalsSiteMapping, foundSiteMap!);
- }
- [Fact]
- public void FindCSyntaxFundamentalsSanitizedWithAnchorReturnsCorrectSiteMap()
- {
- SiteMapping? foundSiteMap = SiteMapping.Find("c-syntax-fundamentals#hello-world", GetSiteMap());
- Assert.NotNull(foundSiteMap);
- Assert.Equal(CSyntaxFundamentalsSiteMapping, foundSiteMap!);
- }
-}
diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
index a385322a..8c84b992 100644
--- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
+++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs
@@ -1,8 +1,78 @@
-ο»Ώusing Microsoft.AspNetCore.Mvc.Testing;
+ο»Ώusing EssentialCSharp.Web.Data;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
namespace EssentialCSharp.Web.Tests;
-internal sealed class WebApplicationFactory : WebApplicationFactory
+public sealed class WebApplicationFactory : WebApplicationFactory
{
+ private static string SqlConnectionString => $"DataSource=file:{Guid.NewGuid()}?mode=memory&cache=shared";
+ private SqliteConnection? _Connection;
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ ServiceDescriptor? descriptor = services.SingleOrDefault(
+ d => d.ServiceType ==
+ typeof(DbContextOptions));
+
+ if (descriptor != null)
+ {
+ services.Remove(descriptor);
+ }
+
+ _Connection = new SqliteConnection(SqlConnectionString);
+ _Connection.Open();
+
+ services.AddDbContext(options =>
+ {
+ options.UseSqlite(_Connection);
+ });
+
+ using ServiceProvider serviceProvider = services.BuildServiceProvider();
+ using IServiceScope scope = serviceProvider.CreateScope();
+ IServiceProvider scopedServices = scope.ServiceProvider;
+ EssentialCSharpWebContext db = scopedServices.GetRequiredService();
+
+ db.Database.EnsureCreated();
+ });
+ }
+
+ ///
+ /// Executes an action within a service scope, handling scope creation and cleanup automatically.
+ ///
+ /// The return type of the action
+ /// The action to execute with the scoped service provider
+ /// The result of the action
+ public T InServiceScope(Func action)
+ {
+ var factory = Services.GetRequiredService();
+ using var scope = factory.CreateScope();
+ return action(scope.ServiceProvider);
+ }
+
+ ///
+ /// Executes an action within a service scope, handling scope creation and cleanup automatically.
+ ///
+ /// The action to execute with the scoped service provider
+ public void InServiceScope(Action action)
+ {
+ var factory = Services.GetRequiredService();
+ using var scope = factory.CreateScope();
+ action(scope.ServiceProvider);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ {
+ _Connection?.Dispose();
+ _Connection = null;
+ }
+ }
}
diff --git a/EssentialCSharp.Web.sln b/EssentialCSharp.Web.sln
deleted file mode 100644
index a184136a..00000000
--- a/EssentialCSharp.Web.sln
+++ /dev/null
@@ -1,41 +0,0 @@
-ο»Ώ
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.32014.148
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{18ABEFF6-6517-4245-B77E-E67D8B6B4682}"
- ProjectSection(SolutionItems) = preProject
- .editorconfig = .editorconfig
- ..\.gitattributes = ..\.gitattributes
- ..\azure-pipelines.yml = ..\azure-pipelines.yml
- Directory.Build.props = Directory.Build.props
- Directory.Build.targets = Directory.Build.targets
- nuget.config = nuget.config
- EndProjectSection
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EssentialCSharp.Web", "EssentialCSharp.Web\EssentialCSharp.Web.csproj", "{B560B909-5FA2-4070-BDE3-1A4DDAA04E12}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EssentialCSharp.Web.Tests", "EssentialCSharp.Web.Tests\EssentialCSharp.Web.Tests.csproj", "{5717B439-2CFF-4BC5-A1DC-48BBF0FBE50F}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {B560B909-5FA2-4070-BDE3-1A4DDAA04E12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B560B909-5FA2-4070-BDE3-1A4DDAA04E12}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B560B909-5FA2-4070-BDE3-1A4DDAA04E12}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B560B909-5FA2-4070-BDE3-1A4DDAA04E12}.Release|Any CPU.Build.0 = Release|Any CPU
- {5717B439-2CFF-4BC5-A1DC-48BBF0FBE50F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5717B439-2CFF-4BC5-A1DC-48BBF0FBE50F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5717B439-2CFF-4BC5-A1DC-48BBF0FBE50F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5717B439-2CFF-4BC5-A1DC-48BBF0FBE50F}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {0EDAAE0E-CC92-4F4C-AE09-6BDE77693EE2}
- EndGlobalSection
-EndGlobal
diff --git a/EssentialCSharp.Web.slnx b/EssentialCSharp.Web.slnx
new file mode 100644
index 00000000..aa0e7b94
--- /dev/null
+++ b/EssentialCSharp.Web.slnx
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs
new file mode 100644
index 00000000..a13c4bfb
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs
@@ -0,0 +1,16 @@
+ο»Ώusing EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace EssentialCSharp.Web.Data;
+
+public class EssentialCSharpWebContext(DbContextOptions options) : IdentityDbContext(options)
+{
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ base.OnModelCreating(builder);
+ // Customize the ASP.NET Identity model and override the defaults if needed.
+ // For example, you can rename the ASP.NET Identity table names and more.
+ // Add your customizations after calling base.OnModelCreating(builder);
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs
new file mode 100644
index 00000000..04e02555
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs
@@ -0,0 +1,14 @@
+ο»Ώusing Microsoft.AspNetCore.Identity;
+
+namespace EssentialCSharp.Web.Areas.Identity.Data;
+
+// Add profile data for application users by adding properties to the EssentialCSharpWebUser class
+public class EssentialCSharpWebUser : IdentityUser
+{
+ [ProtectedPersonalData]
+ public virtual string? FirstName { get; set; }
+ [ProtectedPersonalData]
+ public virtual string? LastName { get; set; }
+ public int ReferralCount { get; set; }
+}
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml
new file mode 100644
index 00000000..017f6ff4
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml
@@ -0,0 +1,10 @@
+ο»Ώ@page
+@model AccessDeniedModel
+@{
+ ViewData["Title"] = "Access denied";
+}
+
+
+
@ViewData["Title"]
+
You do not have access to this resource.
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs
new file mode 100644
index 00000000..6e3f9335
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs
@@ -0,0 +1,15 @@
+ο»Ώusing Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+///
+/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+/// directly from your code. This API may change or be removed in future releases.
+///
+public class AccessDeniedModel : PageModel
+{
+
+ public void OnGet()
+ {
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmail.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmail.cshtml
new file mode 100644
index 00000000..2deb2e52
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmail.cshtml
@@ -0,0 +1,8 @@
+ο»Ώ@page
+@model ConfirmEmailModel
+@{
+ ViewData["Title"] = "Confirm email";
+}
+
+
@ViewData["Title"]
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs
new file mode 100644
index 00000000..a7fc8785
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs
@@ -0,0 +1,32 @@
+ο»Ώusing System.Text;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class ConfirmEmailModel(UserManager userManager) : PageModel
+{
+ [TempData]
+ public string StatusMessage { get; set; } = string.Empty;
+ public async Task OnGetAsync(string? userId, string? code)
+ {
+ if (userId is null || code is null)
+ {
+ return RedirectToPage("/Index");
+ }
+
+ EssentialCSharpWebUser? user = await userManager.FindByIdAsync(userId);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userId}'.");
+ }
+
+ code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
+ IdentityResult result = await userManager.ConfirmEmailAsync(user, code);
+ StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
+ return Page();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
new file mode 100644
index 00000000..114fa88a
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
@@ -0,0 +1,8 @@
+ο»Ώ@page
+@model ConfirmEmailChangeModel
+@{
+ ViewData["Title"] = "Confirm email change";
+}
+
+
@ViewData["Title"]
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs
new file mode 100644
index 00000000..9c86b055
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs
@@ -0,0 +1,49 @@
+ο»Ώusing System.Text;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class ConfirmEmailChangeModel(UserManager userManager, SignInManager signInManager) : PageModel
+{
+ [TempData]
+ public string StatusMessage { get; set; } = string.Empty;
+
+ public async Task OnGetAsync(string? userId, string? email, string? code)
+ {
+ if (userId is null || email is null || code is null)
+ {
+ return RedirectToPage("/Index");
+ }
+
+ EssentialCSharpWebUser? user = await userManager.FindByIdAsync(userId);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userId}'.");
+ }
+
+ code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
+ IdentityResult result = await userManager.ChangeEmailAsync(user, email, code);
+ if (!result.Succeeded)
+ {
+ StatusMessage = "Error changing email.";
+ return Page();
+ }
+
+ // In our UI email and user name are one and the same, so when we update the email
+ // we need to update the user name.
+ IdentityResult setUserNameResult = await userManager.SetUserNameAsync(user, email);
+ if (!setUserNameResult.Succeeded)
+ {
+ StatusMessage = "Error changing user name.";
+ return Page();
+ }
+
+ await signInManager.RefreshSignInAsync(user);
+ StatusMessage = "Thank you for confirming your email change.";
+ return Page();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml
new file mode 100644
index 00000000..15d8f352
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml
@@ -0,0 +1,33 @@
+ο»Ώ@page
+@model ExternalLoginModel
+@{
+ ViewData["Title"] = "Register";
+}
+
+
@ViewData["Title"]
+
Associate your @Model.ProviderDisplayName account.
+
+
+
+ You've successfully authenticated with @Model.ProviderDisplayName.
+ Please enter an email address for this site below and click the Register button to finish
+ logging in.
+
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs
new file mode 100644
index 00000000..1316d553
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs
@@ -0,0 +1,213 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using System.Security.Claims;
+using System.Text;
+using System.Text.Encodings.Web;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using EssentialCSharp.Web.Services.Referrals;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+[AllowAnonymous]
+public class ExternalLoginModel(
+ SignInManager signInManager,
+ UserManager userManager,
+ IUserStore userStore,
+ ILogger logger,
+ IEmailSender emailSender,
+ IUserEmailStore emailStore,
+ IReferralService referralService) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public string? ProviderDisplayName { get; set; }
+
+ public string? ReturnUrl { get; set; }
+
+ [TempData]
+ public string? ErrorMessage { get; set; }
+
+ public class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ public string? Email { get; set; }
+ }
+
+ public IActionResult OnGet() => RedirectToPage("./Login");
+
+ public IActionResult OnPost(string provider, string? returnUrl = null)
+ {
+ // Request a redirect to the external login provider.
+ string redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl }) ?? "/";
+ Microsoft.AspNetCore.Authentication.AuthenticationProperties properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
+ return new ChallengeResult(provider, properties);
+ }
+
+ public async Task OnGetCallbackAsync(string? returnUrl = null, string? remoteError = null)
+ {
+ returnUrl ??= Url.Content("~/");
+ if (remoteError is not null)
+ {
+ ErrorMessage = $"Error from external provider: {remoteError}";
+ return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+ }
+ ExternalLoginInfo? info = await signInManager.GetExternalLoginInfoAsync();
+ if (info is null)
+ {
+ ErrorMessage = "Error loading external login information.";
+ return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+ }
+
+ // Sign in the user with this external login provider if the user already has a login.
+ Microsoft.AspNetCore.Identity.SignInResult result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
+ if (result.Succeeded)
+ {
+ logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity?.Name, info.LoginProvider);
+ // Ensure referral ID is set for the user
+ var user = await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
+ if (user != null)
+ {
+ await referralService.EnsureReferralIdAsync(user);
+ // Refresh sign-in to pick up the newly added referral ID claim
+ await signInManager.RefreshSignInAsync(user);
+ }
+ return LocalRedirect(returnUrl);
+ }
+ if (result.IsLockedOut)
+ {
+ return RedirectToPage("./Lockout");
+ }
+ else
+ {
+ // If the user does not have an account, then ask the user to create an account.
+ ReturnUrl = returnUrl;
+ ProviderDisplayName = info.ProviderDisplayName;
+ if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
+ {
+ Input = new()
+ {
+ Email = info.Principal.FindFirstValue(ClaimTypes.Email)
+ };
+ }
+ return Page();
+ }
+ }
+
+ public async Task OnPostConfirmationAsync(string? returnUrl = null)
+ {
+ returnUrl ??= Url.Content("~/");
+ // Get the information about the user from the external login provider
+ ExternalLoginInfo? info = await signInManager.GetExternalLoginInfoAsync();
+ if (info is null)
+ {
+ ErrorMessage = "Error loading external login information during confirmation.";
+ return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+ }
+
+ if (ModelState.IsValid)
+ {
+ if (Input.Email is null)
+ {
+ ErrorMessage = "Error: Email may not be null.";
+ return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+ }
+ EssentialCSharpWebUser user = CreateUser();
+
+ EssentialCSharpWebUser? existingUser = await userManager.FindByEmailAsync(Input.Email).ConfigureAwait(false);
+ if (existingUser is null)
+ {
+ await userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
+ await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
+ IdentityResult result = await userManager.CreateAsync(user);
+ if (result.Succeeded)
+ {
+ result = await userManager.AddLoginAsync(user, info);
+ if (result.Succeeded)
+ {
+ logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);
+ return await SendConfirmationEmail(returnUrl, info, user);
+ }
+ }
+ foreach (IdentityError error in result.Errors)
+ {
+ ModelState.AddModelError(string.Empty, error.Description);
+ }
+ }
+ else
+ {
+ if (!existingUser.EmailConfirmed)
+ {
+ await SendConfirmationEmail(returnUrl, info, existingUser);
+ }
+ }
+ }
+
+ ProviderDisplayName = info.ProviderDisplayName;
+ ReturnUrl = returnUrl;
+ ModelState.AddModelError(string.Empty, "Please check confirmation email to complete registration.");
+ return Page();
+ }
+
+ private async Task SendConfirmationEmail(string returnUrl, ExternalLoginInfo info, EssentialCSharpWebUser user)
+ {
+ if (Input.Email is null)
+ {
+ ErrorMessage = "Error: Email may not be null.";
+ return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+ }
+ string userId = await userManager.GetUserIdAsync(user);
+ string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ConfirmEmail",
+ pageHandler: null,
+ values: new { area = "Identity", userId = userId, code = code },
+ protocol: Request.Scheme);
+
+ if (callbackUrl is null)
+ {
+ ErrorMessage = "Error: callback url unexpectedly null.";
+ return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
+ }
+
+ await emailSender.SendEmailAsync(Input.Email, "Confirm your email",
+ $"Please confirm your account by clicking here.");
+
+ // If account confirmation is required, we need to show the link if we don't have a real email sender
+ if (userManager.Options.SignIn.RequireConfirmedAccount)
+ {
+ return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
+ }
+
+ // Ensure referral ID is set for the new user before signing in
+ await referralService.EnsureReferralIdAsync(user);
+ await signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
+ return LocalRedirect(returnUrl);
+ }
+
+ private EssentialCSharpWebUser CreateUser()
+ {
+ try
+ {
+ return new EssentialCSharpWebUser();
+ }
+ catch (MissingMethodException innerException)
+ {
+ throw new InvalidOperationException($"Can't create an instance of '{nameof(EssentialCSharpWebUser)}'. " +
+ $"Ensure that '{nameof(EssentialCSharpWebUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
+ $"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml", innerException);
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml
new file mode 100644
index 00000000..54b477e1
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml
@@ -0,0 +1,26 @@
+@page
+@model ForgotPasswordModel
+@{
+ ViewData["Title"] = "Forgot your password?";
+}
+
+
@ViewData["Title"]
+
Enter your email.
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
new file mode 100644
index 00000000..305730b7
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
@@ -0,0 +1,71 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+
+ [Required]
+ [EmailAddress]
+ public string? Email { get; set; }
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (ModelState.IsValid)
+ {
+ if (Input.Email is null)
+ {
+ return RedirectToPage("./ForgotPasswordConfirmation");
+ }
+ EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
+ if (user is null || !(await userManager.IsEmailConfirmedAsync(user)))
+ {
+ // Don't reveal that the user does not exist or is not confirmed
+ return RedirectToPage("./ForgotPasswordConfirmation");
+ }
+
+ // For more information on how to enable account confirmation and password reset please
+ // visit https://go.microsoft.com/fwlink/?LinkID=532713
+ string code = await userManager.GeneratePasswordResetTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ResetPassword",
+ pageHandler: null,
+ values: new { area = "Identity", code },
+ protocol: Request.Scheme);
+
+ if (callbackUrl is null)
+ {
+ return RedirectToPage("./ForgotPasswordConfirmation");
+ }
+
+ await emailSender.SendEmailAsync(
+ Input.Email,
+ "Reset Password",
+ $"Please reset your password by clicking here.");
+
+ return RedirectToPage("./ForgotPasswordConfirmation");
+ }
+
+ return Page();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml
new file mode 100644
index 00000000..2fccecf0
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml
@@ -0,0 +1,10 @@
+ο»Ώ@page
+@model ForgotPasswordConfirmation
+@{
+ ViewData["Title"] = "Forgot password confirmation";
+}
+
+
@ViewData["Title"]
+
+ Please check your email to reset your password. If you can't find the email, please check your spam folder.
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs
new file mode 100644
index 00000000..1be625d3
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs
@@ -0,0 +1,17 @@
+ο»Ώusing Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+///
+/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+/// directly from your code. This API may change or be removed in future releases.
+///
+[AllowAnonymous]
+public class ForgotPasswordConfirmation : PageModel
+{
+
+ public void OnGet()
+ {
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Lockout.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Lockout.cshtml
new file mode 100644
index 00000000..4eded882
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Lockout.cshtml
@@ -0,0 +1,10 @@
+ο»Ώ@page
+@model LockoutModel
+@{
+ ViewData["Title"] = "Locked out";
+}
+
+
+
@ViewData["Title"]
+
This account has been locked out, please try again later.
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Lockout.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Lockout.cshtml.cs
new file mode 100644
index 00000000..2deff5a2
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Lockout.cshtml.cs
@@ -0,0 +1,17 @@
+ο»Ώusing Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+///
+/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+/// directly from your code. This API may change or be removed in future releases.
+///
+[AllowAnonymous]
+public class LockoutModel : PageModel
+{
+
+ public void OnGet()
+ {
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml
new file mode 100644
index 00000000..ddb1f9a2
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml
@@ -0,0 +1,86 @@
+ο»Ώ@page
+@model LoginModel
+
+@{
+ ViewData["Title"] = "Log in";
+}
+
+
+
+@section Scripts {
+
+}
\ No newline at end of file
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs
new file mode 100644
index 00000000..a2434d0a
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs
@@ -0,0 +1,86 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class LoginWith2faModel(
+ SignInManager signInManager,
+ UserManager userManager,
+ ILogger logger) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public bool RememberMe { get; set; }
+
+ public string? ReturnUrl { get; set; }
+
+ public class InputModel
+ {
+ [Required]
+ [StringLength(ValidationMessages.VerificationCodeMaximumLength, ErrorMessage = ValidationMessages.StringLengthErrorMessage, MinimumLength = ValidationMessages.VerificationCodeMaximumLength)]
+ [DataType(DataType.Text)]
+ [Display(Name = "Authenticator code")]
+ public string? TwoFactorCode { get; set; }
+
+ [Display(Name = "Remember this machine")]
+ public bool RememberMachine { get; set; }
+ }
+
+ public async Task OnGetAsync(bool rememberMe, string? returnUrl = null)
+ {
+ // Ensure the user has gone through the username & password screen first
+ _ = await signInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+ if (returnUrl is not null) ReturnUrl = returnUrl;
+ RememberMe = rememberMe;
+
+ return Page();
+ }
+
+ public async Task OnPostAsync(bool rememberMe, string? returnUrl = null)
+ {
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ returnUrl ??= Url.Content("~/");
+
+ EssentialCSharpWebUser user = await signInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+ if (Input.TwoFactorCode is null)
+ {
+ return RedirectToPage("./Lockout", new { ReturnUrl = returnUrl });
+ }
+ string authenticatorCode = Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);
+
+ Microsoft.AspNetCore.Identity.SignInResult result = await signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);
+
+ // Not sure what this is used for but was in identity scaffolding so hesitant to remove without understanding
+ _ = await userManager.GetUserIdAsync(user);
+
+ if (result.Succeeded)
+ {
+ logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id);
+ return LocalRedirect(returnUrl);
+ }
+ else if (result.IsLockedOut)
+ {
+ logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id);
+ return RedirectToPage("./Lockout");
+ }
+ else
+ {
+ logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id);
+ ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
+ return Page();
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml
new file mode 100644
index 00000000..0d44e37d
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml
@@ -0,0 +1,29 @@
+ο»Ώ@page
+@model LoginWithRecoveryCodeModel
+@{
+ ViewData["Title"] = "Recovery code verification";
+}
+
+
@ViewData["Title"]
+
+
+ You have requested to log in with a recovery code. This login will not be remembered until you provide
+ an authenticator app code at log in or disable 2FA and log in again.
+
+
+
+
+
+
+
+@section Scripts {
+
+}
\ No newline at end of file
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs
new file mode 100644
index 00000000..9dba5e44
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs
@@ -0,0 +1,77 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class LoginWithRecoveryCodeModel(
+ SignInManager signInManager,
+ UserManager userManager,
+ ILogger logger) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public string? ReturnUrl { get; set; }
+
+ public class InputModel
+ {
+
+ [BindProperty]
+ [Required]
+ [DataType(DataType.Text)]
+ [Display(Name = "Recovery Code")]
+ public string? RecoveryCode { get; set; }
+ }
+
+ public async Task OnGetAsync(string? returnUrl = null)
+ {
+ // Ensure the user has gone through the username & password screen first
+ EssentialCSharpWebUser user = await signInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+ ReturnUrl = returnUrl;
+
+ return Page();
+ }
+
+ public async Task OnPostAsync(string? returnUrl = null)
+ {
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ EssentialCSharpWebUser user = await signInManager.GetTwoFactorAuthenticationUserAsync() ?? throw new InvalidOperationException($"Unable to load two-factor authentication user.");
+ if (Input.RecoveryCode is null)
+ {
+ return Page();
+ }
+ string recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
+
+ Microsoft.AspNetCore.Identity.SignInResult result = await signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
+
+ string userId = await userManager.GetUserIdAsync(user);
+
+ if (result.Succeeded)
+ {
+ logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", user.Id);
+ return LocalRedirect(returnUrl ?? Url.Content("~/"));
+ }
+ if (result.IsLockedOut)
+ {
+ logger.LogWarning("User account locked out.");
+ return RedirectToPage("./Lockout");
+ }
+ else
+ {
+ logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", user.Id);
+ ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
+ return Page();
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Logout.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Logout.cshtml
new file mode 100644
index 00000000..eba64741
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Logout.cshtml
@@ -0,0 +1,21 @@
+ο»Ώ@page
+@model LogoutModel
+@{
+ ViewData["Title"] = "Log out";
+}
+
+
+
You have successfully logged out of the application.
+ }
+ }
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Logout.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Logout.cshtml.cs
new file mode 100644
index 00000000..c869b7fb
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Logout.cshtml.cs
@@ -0,0 +1,25 @@
+ο»Ώusing EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class LogoutModel(SignInManager signInManager, ILogger logger) : PageModel
+{
+ public async Task OnPost(string? returnUrl = null)
+ {
+ await signInManager.SignOutAsync();
+ logger.LogInformation("User logged out.");
+ if (returnUrl is not null)
+ {
+ return LocalRedirect(returnUrl);
+ }
+ else
+ {
+ // This needs to be a redirect so that the browser performs a new
+ // request and the identity for the user gets updated.
+ return RedirectToPage();
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
new file mode 100644
index 00000000..6429c1ae
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
@@ -0,0 +1,36 @@
+ο»Ώ@page
+@model ChangePasswordModel
+@{
+ ViewData["Title"] = "Change password";
+ViewData["ActivePage"] = ManageNavPages.ChangePassword;
+}
+
+
@ViewData["Title"]
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs
new file mode 100644
index 00000000..730871dc
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs
@@ -0,0 +1,101 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class ChangePasswordModel(
+ UserManager userManager,
+ SignInManager signInManager,
+ ILogger logger) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ public class InputModel
+ {
+ [Required]
+ [DataType(DataType.Password)]
+ [Display(Name = "Current password")]
+ public string? OldPassword { get; set; }
+
+ [Required]
+ [StringLength(Web.Services.PasswordRequirementOptions.PasswordMaximumLength, ErrorMessage = ValidationMessages.StringLengthErrorMessage, MinimumLength = Web.Services.PasswordRequirementOptions.PasswordMinimumLength)]
+ [DataType(DataType.Password)]
+ [Display(Name = "New password")]
+ public string? NewPassword { get; set; }
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm new password")]
+ [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
+ public string? ConfirmPassword { get; set; }
+ }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ bool hasPassword = await userManager.HasPasswordAsync(user);
+ if (!hasPassword)
+ {
+ return RedirectToPage("./SetPassword");
+ }
+
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ if (Input.NewPassword is null)
+ {
+ ModelState.AddModelError(string.Empty, "New password is required.");
+ return RedirectToPage();
+ }
+ if (Input.OldPassword is null)
+ {
+ ModelState.AddModelError(string.Empty, "Current password is required.");
+ return RedirectToPage();
+ }
+
+ IdentityResult changePasswordResult = await userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
+ if (!changePasswordResult.Succeeded)
+ {
+ foreach (IdentityError error in changePasswordResult.Errors)
+ {
+ ModelState.AddModelError(string.Empty, error.Description);
+ }
+ return Page();
+ }
+
+ await signInManager.RefreshSignInAsync(user);
+ logger.LogInformation("User changed their password successfully.");
+ StatusMessage = "Your password has been changed.";
+
+ return RedirectToPage();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml
new file mode 100644
index 00000000..be1d3ca3
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml
@@ -0,0 +1,33 @@
+ο»Ώ@page
+@model DeletePersonalDataModel
+@{
+ ViewData["Title"] = "Delete Personal Data";
+ViewData["ActivePage"] = ManageNavPages.PersonalData;
+}
+
+
@ViewData["Title"]
+
+
+
+ Deleting this data will permanently remove your account, and this cannot be recovered.
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs
new file mode 100644
index 00000000..94658086
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs
@@ -0,0 +1,79 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class DeletePersonalDataModel(
+ UserManager userManager,
+ SignInManager signInManager,
+ ILogger logger) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+ [Required]
+ [DataType(DataType.Password)]
+ public string? Password { get; set; }
+ }
+
+ public bool RequirePassword { get; set; }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ RequirePassword = await userManager.HasPasswordAsync(user);
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ RequirePassword = await userManager.HasPasswordAsync(user);
+ if (RequirePassword)
+ {
+ if (Input.Password is null)
+ {
+ ModelState.AddModelError(string.Empty, "Please enter a password.");
+ return Page();
+ }
+ if (!await userManager.CheckPasswordAsync(user, Input.Password))
+ {
+ ModelState.AddModelError(string.Empty, "Incorrect password.");
+ return Page();
+ }
+ }
+
+ IdentityResult result = await userManager.DeleteAsync(user);
+ string userId = await userManager.GetUserIdAsync(user);
+ if (!result.Succeeded)
+ {
+ throw new InvalidOperationException($"Unexpected error occurred deleting user.");
+ }
+
+ await signInManager.SignOutAsync();
+
+ logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
+
+ return Redirect("~/");
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml
new file mode 100644
index 00000000..cde15b12
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml
@@ -0,0 +1,25 @@
+ο»Ώ@page
+@model Disable2faModel
+@{
+ ViewData["Title"] = "Disable two-factor authentication (2FA)";
+ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+
+
@ViewData["Title"]
+
+
+
+ This action only disables 2FA.
+
+
+ Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
+ used in an authenticator app you should reset your authenticator keys.
+
+
+
+
+
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs
new file mode 100644
index 00000000..4ee04cd7
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs
@@ -0,0 +1,49 @@
+ο»Ώusing EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class Disable2faModel(
+ UserManager userManager,
+ ILogger logger) : PageModel
+{
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ if (!await userManager.GetTwoFactorEnabledAsync(user))
+ {
+ throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
+ }
+
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ IdentityResult disable2faResult = await userManager.SetTwoFactorEnabledAsync(user, false);
+ if (!disable2faResult.Succeeded)
+ {
+ throw new InvalidOperationException($"Unexpected error occurred disabling 2FA.");
+ }
+
+ logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userManager.GetUserId(User));
+ StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
+ return RedirectToPage("./TwoFactorAuthentication");
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml
new file mode 100644
index 00000000..23a19a2b
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml
@@ -0,0 +1,12 @@
+ο»Ώ@page
+@model DownloadPersonalDataModel
+@{
+ ViewData["Title"] = "Download Your Data";
+ViewData["ActivePage"] = ManageNavPages.PersonalData;
+}
+
+
@ViewData["Title"]
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
new file mode 100644
index 00000000..1b07b3e2
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs
@@ -0,0 +1,51 @@
+ο»Ώusing System.Text.Json;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class DownloadPersonalDataModel(
+ UserManager userManager,
+ ILogger logger) : PageModel
+{
+ public IActionResult OnGet()
+ {
+ return NotFound();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ logger.LogInformation("User with ID '{UserId}' asked for their personal data.", userManager.GetUserId(User));
+
+ // Only include personal data for download
+ var personalData = new Dictionary();
+ IEnumerable personalDataProps = typeof(EssentialCSharpWebUser).GetProperties().Where(
+ prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
+ foreach (System.Reflection.PropertyInfo p in personalDataProps)
+ {
+ personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
+ }
+
+ IList logins = await userManager.GetLoginsAsync(user);
+ foreach (UserLoginInfo l in logins)
+ {
+ personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
+ }
+ string? authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user);
+ if (!string.IsNullOrWhiteSpace(authenticatorKey))
+ {
+ personalData.Add($"Authenticator Key", authenticatorKey);
+ }
+
+ Response.Headers.Append("Content-Disposition", "attachment; filename=PersonalData.json");
+ return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json");
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml
new file mode 100644
index 00000000..e2fe09ed
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml
@@ -0,0 +1,44 @@
+ο»Ώ@page
+@model EmailModel
+@{
+ ViewData["Title"] = "Manage Email";
+ViewData["ActivePage"] = ManageNavPages.Email;
+}
+
+
@ViewData["Title"]
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs
new file mode 100644
index 00000000..097e947c
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs
@@ -0,0 +1,158 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class EmailModel(
+ UserManager userManager,
+ SignInManager signInManager,
+ IEmailSender emailSender) : PageModel
+{
+ private readonly UserManager _UserManager = userManager;
+ private readonly SignInManager _SignInManager = signInManager;
+ private readonly IEmailSender _EmailSender = emailSender;
+
+ public string? Email { get; set; }
+
+ public bool IsEmailConfirmed { get; set; }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ [Display(Name = "New email")]
+ public string? NewEmail { get; set; }
+ }
+
+ private async Task LoadAsync(EssentialCSharpWebUser user)
+ {
+ string? email = await _UserManager.GetEmailAsync(user);
+ Email = email;
+
+ Input = new InputModel
+ {
+ NewEmail = email,
+ };
+
+ IsEmailConfirmed = await _UserManager.IsEmailConfirmedAsync(user);
+ }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'.");
+ }
+
+ await LoadAsync(user);
+ return Page();
+ }
+
+ public async Task OnPostChangeEmailAsync()
+ {
+ EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'.");
+ }
+
+ if (!ModelState.IsValid)
+ {
+ await LoadAsync(user);
+ return Page();
+ }
+
+ string? email = await _UserManager.GetEmailAsync(user);
+ if (Input.NewEmail != email)
+ {
+ string userId = await _UserManager.GetUserIdAsync(user);
+ if (Input.NewEmail is null)
+ {
+ StatusMessage = "Please enter in a new email.";
+ return RedirectToPage();
+ }
+ string code = await _UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ConfirmEmailChange",
+ pageHandler: null,
+ values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code },
+ protocol: Request.Scheme);
+ if (callbackUrl is null)
+ {
+ StatusMessage = "Error: callback url unexpectedly null.";
+ return RedirectToPage();
+ }
+ await _EmailSender.SendEmailAsync(
+ Input.NewEmail,
+ "Confirm your email",
+ $"Please confirm your account by clicking here.");
+
+ StatusMessage = "Confirmation link to change email sent. Please check your email. If you can't find the email, please check your spam folder.";
+ return RedirectToPage();
+ }
+
+ StatusMessage = "Your email is unchanged.";
+ return RedirectToPage();
+ }
+
+ public async Task OnPostSendVerificationEmailAsync()
+ {
+ EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'.");
+ }
+
+ if (!ModelState.IsValid)
+ {
+ await LoadAsync(user);
+ return Page();
+ }
+
+ string userId = await _UserManager.GetUserIdAsync(user);
+ string? email = await _UserManager.GetEmailAsync(user);
+ string code = await _UserManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ConfirmEmail",
+ pageHandler: null,
+ values: new { area = "Identity", userId = userId, code = code },
+ protocol: Request.Scheme);
+ if (callbackUrl is null)
+ {
+ StatusMessage = "Error: callback url unexpectedly null.";
+ return RedirectToPage();
+ }
+ if (email is null)
+ {
+ return NotFound($"Unable to load user email with ID '{_UserManager.GetUserId(User)}'.");
+ }
+ await _EmailSender.SendEmailAsync(
+ email,
+ "Confirm your email",
+ $"Please confirm your account by clicking here.");
+
+ StatusMessage = "Verification email sent. Please check your email. If you can't find the email, please check your spam folder.";
+ return RedirectToPage();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml
new file mode 100644
index 00000000..53d5e7da
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml
@@ -0,0 +1,69 @@
+@page
+@model EnableAuthenticatorModel
+@{
+ ViewData["Title"] = "Configure authenticator app";
+ ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+
+
@ViewData["Title"]
+
+
To use an authenticator app go through the following steps:
+
+
+
+ Download a two-factor authenticator app such as:
+
Scan the QR Code or enter this key @Model.SharedKey into your two factor authenticator app. Spaces and casing do not matter.
+
+
+
+
+
+ Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
+ with a unique code. Enter the code in the confirmation box below.
+
+
+
+
+
+
+
+
+
+
+@section Scripts {
+
+
+
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs
new file mode 100644
index 00000000..75db8eb2
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml.cs
@@ -0,0 +1,162 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Text;
+using System.Text.Encodings.Web;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class EnableAuthenticatorModel(
+ UserManager userManager,
+ ILogger logger,
+ UrlEncoder urlEncoder) : PageModel
+{
+ private static readonly CompositeFormat _AuthenticatorUriFormat = CompositeFormat.Parse("otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6");
+
+ public string? SharedKey { get; set; }
+
+ public string? AuthenticatorUri { get; set; }
+
+ [TempData]
+ public string[]? RecoveryCodes { get; set; }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+
+ [Required]
+ [StringLength(ValidationMessages.VerificationCodeMaximumLength, ErrorMessage = ValidationMessages.StringLengthErrorMessage, MinimumLength = ValidationMessages.VerificationCodeMinimumLength)]
+ [DataType(DataType.Text)]
+ [Display(Name = "Verification Code")]
+ public string? Code { get; set; }
+ }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ await LoadSharedKeyAndQrCodeUriAsync(user);
+
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ if (!ModelState.IsValid)
+ {
+ await LoadSharedKeyAndQrCodeUriAsync(user);
+ return Page();
+ }
+
+ if (Input.Code is null)
+ {
+ return RedirectToPage("./TwoFactorAuthentication");
+ }
+ // Strip spaces and hyphens
+ string verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
+
+ bool is2faTokenValid = await userManager.VerifyTwoFactorTokenAsync(
+ user, userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
+
+ if (!is2faTokenValid)
+ {
+ ModelState.AddModelError(nameof(Input.Code), "Verification code is invalid.");
+ await LoadSharedKeyAndQrCodeUriAsync(user);
+ return Page();
+ }
+
+ await userManager.SetTwoFactorEnabledAsync(user, true);
+ string userId = await userManager.GetUserIdAsync(user);
+ logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
+
+ StatusMessage = "Your authenticator app has been verified.";
+
+ if (await userManager.CountRecoveryCodesAsync(user) == 0)
+ {
+ IEnumerable? recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+ if (recoveryCodes is null)
+ {
+ return RedirectToPage("./TwoFactorAuthentication");
+ }
+ RecoveryCodes = recoveryCodes.ToArray();
+ return RedirectToPage("./ShowRecoveryCodes");
+ }
+ else
+ {
+ return RedirectToPage("./TwoFactorAuthentication");
+ }
+ }
+
+ private async Task LoadSharedKeyAndQrCodeUriAsync(EssentialCSharpWebUser user)
+ {
+ // Load the authenticator key & QR code URI to display on the form
+ string? unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
+ if (string.IsNullOrEmpty(unformattedKey))
+ {
+ await userManager.ResetAuthenticatorKeyAsync(user);
+ unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
+ }
+ if (!string.IsNullOrEmpty(unformattedKey))
+ {
+ SharedKey = FormatKey(unformattedKey);
+ }
+
+ string? email = await userManager.GetEmailAsync(user);
+ if (!string.IsNullOrEmpty(email) && !string.IsNullOrEmpty(unformattedKey))
+ {
+ AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey);
+ }
+ }
+
+#pragma warning disable CA1822 // Mark members as static
+ private string FormatKey(string unformattedKey)
+#pragma warning restore CA1822 // Mark members as static
+ {
+ var result = new StringBuilder();
+ int currentPosition = 0;
+ while (currentPosition + 4 < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
+ currentPosition += 4;
+ }
+ if (currentPosition < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.AsSpan(currentPosition));
+ }
+
+ return result.ToString().ToLowerInvariant();
+ }
+
+ private string GenerateQrCodeUri(string email, string unformattedKey)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ _AuthenticatorUriFormat,
+ urlEncoder.Encode("EssentialCSharp.com"),
+ urlEncoder.Encode(email),
+ unformattedKey);
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml
new file mode 100644
index 00000000..7c397e57
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml
@@ -0,0 +1,53 @@
+ο»Ώ@page
+@model ExternalLoginsModel
+@{
+ ViewData["Title"] = "Manage your external logins";
+ ViewData["ActivePage"] = ManageNavPages.ExternalLogins;
+}
+
+
+@if (Model.CurrentLogins?.Count > 0)
+{
+
Registered Logins
+
+
+ @foreach (var login in Model.CurrentLogins)
+ {
+
+
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs
new file mode 100644
index 00000000..808ccc3d
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs
@@ -0,0 +1,97 @@
+ο»Ώusing EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class ExternalLoginsModel(
+ UserManager userManager,
+ SignInManager signInManager,
+ IUserPasswordStore userPasswordStore) : PageModel
+{
+ public IList? CurrentLogins { get; set; }
+
+ public IList? OtherLogins { get; set; }
+
+ public bool ShowRemoveButton { get; set; }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ CurrentLogins = await userManager.GetLoginsAsync(user);
+ OtherLogins = (await signInManager.GetExternalAuthenticationSchemesAsync())
+ .Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider))
+ .ToList();
+
+ string? passwordHash = null;
+ passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
+
+ ShowRemoveButton = passwordHash is not null || CurrentLogins.Count > 1;
+ return Page();
+ }
+
+ public async Task OnPostRemoveLoginAsync(string loginProvider, string providerKey)
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ IdentityResult result = await userManager.RemoveLoginAsync(user, loginProvider, providerKey);
+ if (!result.Succeeded)
+ {
+ StatusMessage = "The external login was not removed.";
+ return RedirectToPage();
+ }
+
+ await signInManager.RefreshSignInAsync(user);
+ StatusMessage = "The external login was removed.";
+ return RedirectToPage();
+ }
+
+ public async Task OnPostLinkLoginAsync(string provider)
+ {
+ // Clear the existing external cookie to ensure a clean login process
+ await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
+
+ // Request a redirect to the external login provider to link a login for the current user
+ string? redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback");
+ AuthenticationProperties properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, userManager.GetUserId(User));
+ return new ChallengeResult(provider, properties);
+ }
+
+ public async Task OnGetLinkLoginCallbackAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ string userId = await userManager.GetUserIdAsync(user);
+ ExternalLoginInfo info = await signInManager.GetExternalLoginInfoAsync(userId) ?? throw new InvalidOperationException($"Unexpected error occurred loading external login info.");
+ IdentityResult result = await userManager.AddLoginAsync(user, info);
+ if (!result.Succeeded)
+ {
+ StatusMessage = "The external login was not added. External logins can only be associated with one account.";
+ return RedirectToPage();
+ }
+
+ // Clear the existing external cookie to ensure a clean login process
+ await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
+
+ StatusMessage = "The external login was added.";
+ return RedirectToPage();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml
new file mode 100644
index 00000000..6c8d7b6b
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml
@@ -0,0 +1,27 @@
+ο»Ώ@page
+@model GenerateRecoveryCodesModel
+@{
+ ViewData["Title"] = "Generate two-factor authentication (2FA) recovery codes";
+ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
+}
+
+
+
@ViewData["Title"]
+
+
+
+ Put these codes in a safe place.
+
+
+ If you lose your device and don't have the recovery codes you will lose access to your account.
+
+
+ Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
+ used in an authenticator app you should reset your authenticator keys.
+
+
+
+
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs
new file mode 100644
index 00000000..7d0df6e8
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs
@@ -0,0 +1,57 @@
+ο»Ώusing EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class GenerateRecoveryCodesModel(
+ UserManager userManager,
+ ILogger logger) : PageModel
+{
+ [TempData]
+ public string[]? RecoveryCodes { get; set; }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ bool isTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user);
+ if (!isTwoFactorEnabled)
+ {
+ throw new InvalidOperationException($"Cannot generate recovery codes for user because they do not have 2FA enabled.");
+ }
+
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ bool isTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user);
+ string userId = await userManager.GetUserIdAsync(user);
+ if (!isTwoFactorEnabled)
+ {
+ throw new InvalidOperationException($"Cannot generate recovery codes for user as they do not have 2FA enabled.");
+ }
+
+ IEnumerable? recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
+ RecoveryCodes = recoveryCodes?.ToArray();
+
+ logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
+ StatusMessage = "You have generated new recovery codes.";
+ return RedirectToPage("./ShowRecoveryCodes");
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml
new file mode 100644
index 00000000..f63c961f
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml
@@ -0,0 +1,43 @@
+ο»Ώ@page
+@model IndexModel
+@{
+ ViewData["Title"] = "Profile";
+ViewData["ActivePage"] = ManageNavPages.Index;
+}
+
+
@ViewData["Title"]
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
new file mode 100644
index 00000000..27474e1e
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
@@ -0,0 +1,130 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class IndexModel(
+ UserManager userManager,
+ SignInManager signInManager) : PageModel
+{
+ public string? Username { get; set; }
+
+ public string? FirstName { get; set; }
+ public string? LastName { get; set; }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+ [Display(Name = "Username")]
+ public string? Username { get; set; }
+
+ [Phone]
+ [Display(Name = "Phone number")]
+ public string? PhoneNumber { get; set; }
+
+ [Display(Name = "First Name")]
+ public string? FirstName { get; set; }
+
+ [Display(Name = "Last Name")]
+ public string? LastName { get; set; }
+ }
+
+ private async Task LoadAsync(EssentialCSharpWebUser user)
+ {
+ string? userName = await userManager.GetUserNameAsync(user);
+ string? phoneNumber = await userManager.GetPhoneNumberAsync(user);
+
+ Input = new InputModel
+ {
+ Username = userName,
+ PhoneNumber = phoneNumber,
+ FirstName = user.FirstName,
+ LastName = user.LastName
+ };
+ }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ await LoadAsync(user);
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ if (!ModelState.IsValid)
+ {
+ await LoadAsync(user);
+ return Page();
+ }
+
+ string? phoneNumber = await userManager.GetPhoneNumberAsync(user);
+ if (Input.PhoneNumber != phoneNumber)
+ {
+ IdentityResult setPhoneResult = await userManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
+ if (!setPhoneResult.Succeeded)
+ {
+ StatusMessage = "Unexpected error when trying to set phone number.";
+ return RedirectToPage();
+ }
+ }
+ string? username = await userManager.GetUserNameAsync(user);
+ if (Input.Username != username)
+ {
+ IdentityResult setUsernameResult = await userManager.SetUserNameAsync(user, Input.Username);
+ if (!setUsernameResult.Succeeded)
+ {
+ StatusMessage = "Unexpected error when trying to set username.";
+ return RedirectToPage();
+ }
+ }
+ if (Input.FirstName != user.FirstName)
+ {
+ user.FirstName = Input.FirstName;
+ IdentityResult setFirstNameResult = await userManager.UpdateAsync(user);
+ if (!setFirstNameResult.Succeeded)
+ {
+ StatusMessage = "Unexpected error when trying to set first name.";
+ return RedirectToPage();
+ }
+ }
+ if (Input.LastName != user.LastName)
+ {
+ user.LastName = Input.LastName;
+ IdentityResult setLastNameResult = await userManager.UpdateAsync(user);
+ if (!setLastNameResult.Succeeded)
+ {
+ StatusMessage = "Unexpected error when trying to set last name.";
+ return RedirectToPage();
+ }
+ }
+
+ await signInManager.RefreshSignInAsync(user);
+ StatusMessage = "Your profile has been updated";
+ return RedirectToPage();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs
new file mode 100644
index 00000000..e362bf1c
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs
@@ -0,0 +1,53 @@
+ο»Ώusing Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+///
+/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+/// directly from your code. This API may change or be removed in future releases.
+///
+public static class ManageNavPages
+{
+ public static string Index => "Index";
+
+ public static string Email => "Email";
+
+ public static string ChangePassword => "ChangePassword";
+
+ public static string DownloadPersonalData => "DownloadPersonalData";
+
+ public static string DeletePersonalData => "DeletePersonalData";
+
+ public static string ExternalLogins => "ExternalLogins";
+
+ public static string PersonalData => "PersonalData";
+
+ public static string TwoFactorAuthentication => "TwoFactorAuthentication";
+
+ public static string Referrals => "Referrals";
+
+ public static string? IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
+
+ public static string? EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
+
+ public static string? ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword);
+
+ public static string? DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData);
+
+ public static string? DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData);
+
+ public static string? ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins);
+
+ public static string? PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData);
+
+ public static string? TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
+
+ public static string? ReferralsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Referrals);
+
+ public static string? PageNavClass(ViewContext viewContext, string page)
+ {
+ string? activePage = viewContext.ViewData["ActivePage"] as string
+ ?? Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);
+ return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
new file mode 100644
index 00000000..2c2458cd
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
@@ -0,0 +1,27 @@
+ο»Ώ@page
+@model PersonalDataModel
+@{
+ ViewData["Title"] = "Personal Data";
+ViewData["ActivePage"] = ManageNavPages.PersonalData;
+}
+
+
@ViewData["Title"]
+
+
+
+
Your account contains personal data that you have given us. This page allows you to download or delete that data.
+
+ Deleting this data will permanently remove your account, and this cannot be recovered.
+
+
+ If you reset your authenticator key your authenticator app will not work until you reconfigure it.
+
+
+ This process disables 2FA until you verify your authenticator app.
+ If you do not complete your authenticator app configuration you may lose access to your account.
+
+
+
+
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs
new file mode 100644
index 00000000..8fd8ef5d
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ResetAuthenticator.cshtml.cs
@@ -0,0 +1,45 @@
+ο»Ώusing EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class ResetAuthenticatorModel(
+ UserManager userManager,
+ SignInManager signInManager,
+ ILogger logger) : PageModel
+{
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ await userManager.SetTwoFactorEnabledAsync(user, false);
+ await userManager.ResetAuthenticatorKeyAsync(user);
+ _ = await userManager.GetUserIdAsync(user);
+ logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id);
+
+ await signInManager.RefreshSignInAsync(user);
+ StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.";
+
+ return RedirectToPage("./EnableAuthenticator");
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml
new file mode 100644
index 00000000..dd29662c
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml
@@ -0,0 +1,35 @@
+ο»Ώ@page
+@model SetPasswordModel
+@{
+ ViewData["Title"] = "Set password";
+ViewData["ActivePage"] = ManageNavPages.ChangePassword;
+}
+
+
Set your password
+
+
+ You do not have a local username/password for this site. Add a local
+ account so you can log in without an external login.
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs
new file mode 100644
index 00000000..a0e06458
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs
@@ -0,0 +1,90 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage;
+
+public class SetPasswordModel(
+ UserManager userManager,
+ SignInManager signInManager) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ [TempData]
+ public string? StatusMessage { get; set; }
+
+ public class InputModel
+ {
+ [Required]
+ [StringLength(Web.Services.PasswordRequirementOptions.PasswordMaximumLength, ErrorMessage = ValidationMessages.StringLengthErrorMessage, MinimumLength = Web.Services.PasswordRequirementOptions.PasswordMinimumLength)]
+ [DataType(DataType.Password)]
+ [Display(Name = "New password")]
+ public string? NewPassword { get; set; }
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm new password")]
+ [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
+ public string? ConfirmPassword { get; set; }
+ }
+
+ public async Task OnGetAsync()
+ {
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ bool hasPassword = await userManager.HasPasswordAsync(user);
+
+ if (hasPassword)
+ {
+ return RedirectToPage("./ChangePassword");
+ }
+
+ return Page();
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ EssentialCSharpWebUser? user = await userManager.GetUserAsync(User);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ if (Input.NewPassword is null)
+ {
+ StatusMessage = "Please enter a new password.";
+ return Page();
+ }
+
+ IdentityResult addPasswordResult = await userManager.AddPasswordAsync(user, Input.NewPassword);
+ if (!addPasswordResult.Succeeded)
+ {
+ foreach (IdentityError error in addPasswordResult.Errors)
+ {
+ ModelState.AddModelError(string.Empty, error.Description);
+ }
+ return Page();
+ }
+
+ await signInManager.RefreshSignInAsync(user);
+ StatusMessage = "Your password has been set.";
+
+ return RedirectToPage();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml
new file mode 100644
index 00000000..0f69d138
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml
@@ -0,0 +1,25 @@
+ο»Ώ@page
+@model ShowRecoveryCodesModel
+@{
+ ViewData["Title"] = "Recovery codes";
+ViewData["ActivePage"] = "TwoFactorAuthentication";
+}
+
+
+
@ViewData["Title"]
+
+
+ Put these codes in a safe place.
+
+
+ If you lose your device and don't have the recovery codes you will lose access to your account.
+
+
+@section Scripts {
+
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
new file mode 100644
index 00000000..5e2db228
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
@@ -0,0 +1,233 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using EssentialCSharp.Web.Models;
+using EssentialCSharp.Web.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Options;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class RegisterModel(
+ UserManager userManager,
+ IUserStore userStore,
+ SignInManager signInManager,
+ ILogger logger,
+ IEmailSender emailSender,
+ ICaptchaService captchaService,
+ IOptions optionsAccessor,
+ IUserEmailStore emailStore) : PageModel
+{
+ public CaptchaOptions CaptchaOptions { get; } = optionsAccessor.Value;
+
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public string? ReturnUrl { get; set; }
+
+ public IList? ExternalLogins { get; set; }
+
+ public class InputModel
+ {
+ [Required]
+ [DataType(DataType.Text)]
+ [Display(Name = "User Name")]
+ public string? UserName { get; set; }
+
+ [Required]
+ [DataType(DataType.Text)]
+ [Display(Name = "First Name")]
+ public string? FirstName { get; set; }
+
+ [Required]
+ [DataType(DataType.Text)]
+ [Display(Name = "Last Name")]
+ public string? LastName { get; set; }
+
+ [Required]
+ [EmailAddress]
+ [Display(Name = "Email")]
+ public string? Email { get; set; }
+
+ [Required]
+ [StringLength(PasswordRequirementOptions.PasswordMaximumLength, ErrorMessage = ValidationMessages.StringLengthErrorMessage, MinimumLength = PasswordRequirementOptions.PasswordMinimumLength)]
+ [DataType(DataType.Password)]
+ [Display(Name = "Password")]
+ public string? Password { get; set; }
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm password")]
+ [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
+ public string? ConfirmPassword { get; set; }
+ }
+
+ public async Task OnGetAsync(string? returnUrl = null)
+ {
+ ReturnUrl = returnUrl;
+ ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
+ }
+
+ public async Task OnPostAsync(string? returnUrl = null)
+ {
+ returnUrl ??= Url.Content("~/");
+ string? hCaptcha_response = Request.Form[CaptchaOptions.HttpPostResponseKeyName];
+
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ if (hCaptcha_response is null)
+ {
+ ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, HCaptchaErrorDetails.GetValue(HCaptchaErrorDetails.MissingInputResponse).FriendlyDescription);
+ return Page();
+ }
+
+ HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response);
+ if (response is null)
+ {
+ ModelState.AddModelError(CaptchaOptions.HttpPostResponseKeyName, "Error: HCaptcha API response unexpectedly null");
+ return Page();
+ }
+
+ // The JSON should also return a field "success" as true
+ // https://docs.hcaptcha.com/#verify-the-user-response-server-side
+ if (response.Success)
+ {
+ ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
+ if (ModelState.IsValid)
+ {
+ EssentialCSharpWebUser user = CreateUser();
+ user.FirstName = Input.FirstName;
+ user.LastName = Input.LastName;
+
+ await userStore.SetUserNameAsync(user, Input.UserName, CancellationToken.None);
+ await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
+ if (Input.Password is null)
+ {
+ logger.LogInformation("Error: Password null; please enter in a password");
+ ModelState.AddModelError(string.Empty, "Error: Password null; please enter in a password");
+ return Page();
+ }
+ IdentityResult result = await userManager.CreateAsync(user, Input.Password);
+
+ if (result.Succeeded)
+ {
+ logger.LogInformation("User created a new account with password.");
+
+ string userId = await userManager.GetUserIdAsync(user);
+ string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ConfirmEmail",
+ pageHandler: null,
+ values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
+ protocol: Request.Scheme);
+
+ if (callbackUrl is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
+ return Page();
+ }
+ if (Input.Email is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: Email may not be null.");
+ return Page();
+ }
+ await emailSender.SendEmailAsync(Input.Email, "Confirm your email",
+ $"Please confirm your account by clicking here.");
+
+ if (userManager.Options.SignIn.RequireConfirmedAccount)
+ {
+ return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
+ }
+ else
+ {
+ await signInManager.SignInAsync(user, isPersistent: false);
+ return LocalRedirect(returnUrl);
+ }
+ }
+ foreach (IdentityError error in result.Errors)
+ {
+ ModelState.AddModelError(string.Empty, error.Description);
+ }
+ }
+ }
+ else
+ {
+ switch (response.ErrorCodes?.Length)
+ {
+ case 0:
+ throw new InvalidOperationException("The HCaptcha determined the passcode is not valid, and does not meet the security criteria");
+ case > 1:
+ throw new InvalidOperationException("HCaptcha returned error codes: " + string.Join(", ", response.ErrorCodes));
+ default:
+ {
+ if (response.ErrorCodes is null)
+ {
+ throw new InvalidOperationException("HCaptcha returned error codes unexpectedly null");
+ }
+ if (HCaptchaErrorDetails.TryGetValue(response.ErrorCodes.Single(), out HCaptchaErrorDetails? details))
+ {
+ switch (details.ErrorCode)
+ {
+ case HCaptchaErrorDetails.MissingInputResponse:
+ case HCaptchaErrorDetails.InvalidInputResponse:
+ case HCaptchaErrorDetails.InvalidOrAlreadySeenResponse:
+ ModelState.AddModelError(string.Empty, details.FriendlyDescription);
+ logger.LogInformation("HCaptcha returned error code: {ErrorDetails}", details.ToString());
+ break;
+ case HCaptchaErrorDetails.BadRequest:
+ ModelState.AddModelError(string.Empty, details.FriendlyDescription);
+ logger.LogInformation("HCaptcha returned error code: {ErrorDetails}", details.ToString());
+ break;
+ case HCaptchaErrorDetails.MissingInputSecret:
+ case HCaptchaErrorDetails.InvalidInputSecret:
+ case HCaptchaErrorDetails.NotUsingDummyPasscode:
+ case HCaptchaErrorDetails.SitekeySecretMismatch:
+ logger.LogCritical("HCaptcha returned error code: {ErrorDetails}", details.ToString());
+ break;
+ default:
+ throw new InvalidOperationException("HCaptcha returned unknown error code: " + details?.ErrorCode);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("HCaptcha returned unknown error code: " + response.ErrorCodes.Single());
+ }
+
+ break;
+ }
+
+ }
+ }
+
+ // If we got this far, something failed, redisplay form
+ return Page();
+ }
+
+ private EssentialCSharpWebUser CreateUser()
+ {
+ try
+ {
+ return new EssentialCSharpWebUser();
+ }
+ catch
+ {
+ throw new InvalidOperationException($"Can't create an instance of '{nameof(EssentialCSharpWebUser)}'. " +
+ $"Ensure that '{nameof(EssentialCSharpWebUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
+ $"override the register page in /Areas/Identity/Pages/Account/Register.cshtml");
+ }
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
new file mode 100644
index 00000000..48869443
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
@@ -0,0 +1,23 @@
+ο»Ώ@page
+@model RegisterConfirmationModel
+@{
+ ViewData["Title"] = "Register confirmation";
+}
+
+
@ViewData["Title"]
+@{
+ if (@Model.DisplayConfirmAccountLink)
+{
+
+ This app does not currently have a real email sender registered, see these docs for how to configure a real email sender.
+ Normally this would be emailed: Click here to confirm your account
+
+ }
+else
+{
+
+ Please check your email to confirm your account. If you can't find the email, please check your spam folder.
+
+ }
+}
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs
new file mode 100644
index 00000000..1f840a4f
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs
@@ -0,0 +1,61 @@
+ο»Ώusing System.Text;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+[AllowAnonymous]
+public class RegisterConfirmationModel : PageModel
+{
+ private readonly UserManager _UserManager;
+ private readonly IEmailSender _Sender;
+
+ public RegisterConfirmationModel(UserManager userManager, IEmailSender sender)
+ {
+ _UserManager = userManager;
+ _Sender = sender;
+ }
+
+ public string? Email { get; set; }
+
+ public bool DisplayConfirmAccountLink { get; set; }
+
+ public string? EmailConfirmationUrl { get; set; }
+
+ public async Task OnGetAsync(string? email, string? returnUrl = null)
+ {
+ if (email is null)
+ {
+ return RedirectToPage("/Index");
+ }
+ returnUrl ??= Url.Content("~/");
+
+ EssentialCSharpWebUser? user = await _UserManager.FindByEmailAsync(email);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with email '{email}'.");
+ }
+
+ Email = email;
+ // Once you add a real email sender, you should remove this code that lets you confirm the account
+ DisplayConfirmAccountLink = false;
+ if (DisplayConfirmAccountLink)
+ {
+ string userId = await _UserManager.GetUserIdAsync(user);
+ string code = await _UserManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ EmailConfirmationUrl = Url.Page(
+ "/Account/ConfirmEmail",
+ pageHandler: null,
+ values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
+ protocol: Request.Scheme);
+ }
+
+ return Page();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml
new file mode 100644
index 00000000..d457b6b8
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml
@@ -0,0 +1,26 @@
+ο»Ώ@page
+@model ResendEmailConfirmationModel
+@{
+ ViewData["Title"] = "Resend email confirmation";
+}
+
+
@ViewData["Title"]
+
Enter your email.
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
new file mode 100644
index 00000000..8ef99283
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs
@@ -0,0 +1,73 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using System.Text;
+using System.Text.Encodings.Web;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+[AllowAnonymous]
+public class ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ public string? Email { get; set; }
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ if (Input.Email is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: Email is null. Please enter in an email");
+ return Page();
+ }
+
+ EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
+ if (user is null)
+ {
+ return NotFound($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
+ }
+
+ string userId = await userManager.GetUserIdAsync(user);
+ string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
+ code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
+ string? callbackUrl = Url.Page(
+ "/Account/ConfirmEmail",
+ pageHandler: null,
+ values: new { userId = userId, code = code },
+ protocol: Request.Scheme);
+
+ if (callbackUrl is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: callback url unexpectedly null.");
+ return Page();
+ }
+ await emailSender.SendEmailAsync(
+ Input.Email,
+ "Confirm your email",
+ $"Please confirm your account by clicking here.");
+
+ ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email. If you can't find the email, please check your spam folder.");
+ return Page();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml
new file mode 100644
index 00000000..e430d01e
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml
@@ -0,0 +1,37 @@
+ο»Ώ@page
+@model ResetPasswordModel
+@{
+ ViewData["Title"] = "Reset password";
+}
+
+
@ViewData["Title"]
+
Reset your password.
+
+
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
new file mode 100644
index 00000000..383de416
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
@@ -0,0 +1,100 @@
+ο»Ώusing System.ComponentModel.DataAnnotations;
+using System.Text;
+using EssentialCSharp.Web.Areas.Identity.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public class ResetPasswordModel(UserManager userManager) : PageModel
+{
+ private InputModel? _Input;
+ [BindProperty]
+ public InputModel Input
+ {
+ get => _Input!;
+ set => _Input = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ public class InputModel
+ {
+ [Required]
+ [EmailAddress]
+ public string? Email { get; set; }
+
+ [Required]
+ [StringLength(Web.Services.PasswordRequirementOptions.PasswordMaximumLength, ErrorMessage = ValidationMessages.StringLengthErrorMessage, MinimumLength = Web.Services.PasswordRequirementOptions.PasswordMinimumLength)]
+ [DataType(DataType.Password)]
+ public string? Password { get; set; }
+
+ [DataType(DataType.Password)]
+ [Display(Name = "Confirm password")]
+ [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
+ public string? ConfirmPassword { get; set; }
+
+ [Required]
+ public string? Code { get; set; }
+
+ }
+
+ public IActionResult OnGet(string? code = null)
+ {
+ if (code is null)
+ {
+ return BadRequest("A code must be supplied for password reset.");
+ }
+ else
+ {
+ Input = new InputModel
+ {
+ Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code))
+ };
+ return Page();
+ }
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (!ModelState.IsValid)
+ {
+ return Page();
+ }
+
+ if (Input.Email is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: Email is required.");
+ return RedirectToPage();
+ }
+ EssentialCSharpWebUser? user = await userManager.FindByEmailAsync(Input.Email);
+ if (user is null)
+ {
+ // Don't reveal that the user does not exist
+ return RedirectToPage("./ResetPasswordConfirmation");
+ }
+
+ if (Input.Password is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: Password is required.");
+ return RedirectToPage();
+ }
+ if (Input.Code is null)
+ {
+ ModelState.AddModelError(string.Empty, "Error: Code is required.");
+ return RedirectToPage();
+ }
+
+ IdentityResult result = await userManager.ResetPasswordAsync(user, Input.Code, Input.Password);
+ if (result.Succeeded)
+ {
+ return RedirectToPage("./ResetPasswordConfirmation");
+ }
+
+ foreach (IdentityError error in result.Errors)
+ {
+ ModelState.AddModelError(string.Empty, error.Description);
+ }
+ return Page();
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml
new file mode 100644
index 00000000..c52552f3
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml
@@ -0,0 +1,10 @@
+ο»Ώ@page
+@model ResetPasswordConfirmationModel
+@{
+ ViewData["Title"] = "Reset password confirmation";
+}
+
+
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs
new file mode 100644
index 00000000..a75f6d05
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs
@@ -0,0 +1,16 @@
+ο»Ώusing Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+///
+/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
+/// directly from your code. This API may change or be removed in future releases.
+///
+[AllowAnonymous]
+public class ResetPasswordConfirmationModel : PageModel
+{
+ public void OnGet()
+ {
+ }
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/ValidationMessages.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ValidationMessages.cs
new file mode 100644
index 00000000..09f2e79d
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/ValidationMessages.cs
@@ -0,0 +1,8 @@
+ο»Ώnamespace EssentialCSharp.Web.Areas.Identity.Pages.Account;
+
+public static class ValidationMessages
+{
+ public const string StringLengthErrorMessage = "The {0} must be at least {2} and at max {1} characters long.";
+ public const int VerificationCodeMaximumLength = 7;
+ public const int VerificationCodeMinimumLength = 6;
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/_StatusMessage.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/_StatusMessage.cshtml
new file mode 100644
index 00000000..567648b5
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/_StatusMessage.cshtml
@@ -0,0 +1,10 @@
+ο»Ώ@model string
+
+@if (!string.IsNullOrEmpty(Model))
+{
+ var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
+
+
+ @Model
+
+}
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/_ViewImports.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/_ViewImports.cshtml
new file mode 100644
index 00000000..b45b7975
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/_ViewImports.cshtml
@@ -0,0 +1 @@
+ο»Ώ@using EssentialCSharp.Web.Areas.Identity.Pages.Account
\ No newline at end of file
diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Error.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Error.cshtml
new file mode 100644
index 00000000..b1f3143a
--- /dev/null
+++ b/EssentialCSharp.Web/Areas/Identity/Pages/Error.cshtml
@@ -0,0 +1,23 @@
+ο»Ώ@page
+@model ErrorModel
+@{
+ ViewData["Title"] = "Error";
+}
+
+
Error.
+
An error occurred while processing your request.
+
+@if (Model.ShowRequestId)
+{
+
+ Request ID:@Model.RequestId
+
+}
+
+
Development Mode
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ Development environment should not be enabled in deployed applications, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the ASPNETCORE_ENVIRONMENT environment variable to Development, and restarting the application.
+