Initial Blazor birth registry app, theming, and services
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
Implemented a Blazor Web App (.NET 8) for a public-by-link birth registry platform, following project guidelines. Added domain entities, EF Core context, and Blazor components for authentication, registry management, and public views. Introduced core services for registries, theming, user context, platform owner bootstrapping, and SMTP email. Included static assets (Bootstrap, favicon), launch settings, Dockerfile, CI workflow, and deployment configs. Added bootstrap.min.css.map for improved CSS debugging.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate datetime tag
|
||||
id: vars
|
||||
run: echo "tag=$(date +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.arnemoerman.be
|
||||
username: arne
|
||||
password: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./src/BirthList.Web/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
git.arnemoerman.be/arne/birthlist:latest
|
||||
git.arnemoerman.be/arne/birthlist:${{ steps.vars.outputs.tag }}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copilot Instructions
|
||||
|
||||
## Project Guidelines
|
||||
- Use Blazor Web App (.NET 8) with an interactive server and CSS isolation; split components into code-behind files when component parameter count grows beyond a few.
|
||||
- Support partial quantities in the purchase flow and display purchased/desired progress; guests must log in before reserving/purchasing.
|
||||
- Authentication must support local accounts plus Microsoft and Google login; admin invite links are single-use and not email-locked.
|
||||
- Money participation defaults to EUR and tracks fulfilled amount per item via bank-transfer contributions with item-linked messages.
|
||||
- Registry visibility is public-by-link initially, but logged-in users who visited a registry should be able to rediscover it in-app.
|
||||
- Use SQL Server as the default provider and set `RequireConfirmedAccount=false` for MVP testing; the first registered account should be the owner with full access.
|
||||
|
||||
## Technical Specifications
|
||||
- Use SMTP via Google initially for email functionalities.
|
||||
- Support registry type theming from day one to enhance user experience.
|
||||
- URL autofetch should utilize OpenGraph/meta tags for better link previews.
|
||||
- Use Blazored.TextEditor for rich text editing.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Build output
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
out/
|
||||
|
||||
# User-specific files
|
||||
*.user
|
||||
*.suo
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Visual Studio / Rider
|
||||
.vs/
|
||||
.idea/
|
||||
*.DotSettings.user
|
||||
|
||||
# Logs and temp files
|
||||
*.log
|
||||
logs/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
packages/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
*.trx
|
||||
|
||||
# Publish artifacts
|
||||
publish/
|
||||
artifacts/
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
**/appsettings.Local.json
|
||||
**/appsettings.Development.Local.json
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Docker overrides
|
||||
docker-compose.override.yml
|
||||
@@ -0,0 +1,10 @@
|
||||
<Solution>
|
||||
<Folder Name="/gitea build/">
|
||||
<File Path=".gitea/workflows/build-and-push.yml" />
|
||||
</Folder>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/BirthList.Domain/BirthList.Domain.csproj" />
|
||||
<Project Path="src/BirthList.Infrastructure/BirthList.Infrastructure.csproj" />
|
||||
<Project Path="src/BirthList.Web/BirthList.Web.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deployment templates
|
||||
|
||||
## Gitea build pipeline
|
||||
- Workflow file: `.gitea/workflows/build-and-push.yml`
|
||||
- Pushes Docker image tags to: `git.arnemoerman.be/arne/birthlist`
|
||||
- `latest`
|
||||
- datetime tag (`yyyyMMddHHmm`)
|
||||
|
||||
## Portainer stack
|
||||
- Stack file: `deploy/portainer-stack.yml`
|
||||
- Env template: `deploy/portainer-stack.env.example`
|
||||
|
||||
## Notes
|
||||
- Set `PAT_TOKEN` secret in Gitea for registry login.
|
||||
- Ensure external Docker network `caddy-net` exists.
|
||||
- OAuth/SMTP env values can stay empty for MVP; related features will be disabled.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Required for SQL Server container and app DB connection
|
||||
SA_PASSWORD=ChangeThisToAStrongPassword123!
|
||||
|
||||
# Optional OAuth (leave empty to disable provider at runtime)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
MICROSOFT_CLIENT_ID=
|
||||
MICROSOFT_CLIENT_SECRET=
|
||||
|
||||
# SMTP (leave username/password/from address empty to disable mail features)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_ENABLE_SSL=true
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_ADDRESS=
|
||||
SMTP_FROM_NAME=Birth Registry
|
||||
@@ -0,0 +1,68 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
birthlist:
|
||||
image: git.arnemoerman.be/arne/birthlist:latest
|
||||
container_name: birthlist
|
||||
ports:
|
||||
- "5022:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
- Data__Provider=SqlServer
|
||||
- ConnectionStrings__DefaultConnection=Server=mssql;User Id=sa;Password=${SA_PASSWORD};Initial Catalog=BirthList;Encrypt=false;TrustServerCertificate=true
|
||||
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID}
|
||||
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET}
|
||||
- Authentication__Microsoft__ClientId=${MICROSOFT_CLIENT_ID}
|
||||
- Authentication__Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET}
|
||||
- Smtp__Host=${SMTP_HOST}
|
||||
- Smtp__Port=${SMTP_PORT}
|
||||
- Smtp__EnableSsl=${SMTP_ENABLE_SSL}
|
||||
- Smtp__UserName=${SMTP_USERNAME}
|
||||
- Smtp__Password=${SMTP_PASSWORD}
|
||||
- Smtp__FromAddress=${SMTP_FROM_ADDRESS}
|
||||
- Smtp__FromName=${SMTP_FROM_NAME}
|
||||
depends_on:
|
||||
- mssql
|
||||
networks:
|
||||
- birthlist-network
|
||||
- caddy-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
container_name: birthlist-db
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=${SA_PASSWORD}
|
||||
- MSSQL_PID=Developer
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- birthlist-mssql-data:/var/opt/mssql
|
||||
networks:
|
||||
- birthlist-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "${SA_PASSWORD}", "-Q", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
birthlist-network:
|
||||
driver: bridge
|
||||
caddy-net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
birthlist-mssql-data:
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class ItemContribution
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RegistryItemId { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public string TransferMessage { get; set; } = string.Empty;
|
||||
public DateTimeOffset ContributedAtUtc { get; set; }
|
||||
|
||||
public RegistryItem RegistryItem { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class ItemPurchase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RegistryItemId { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public int Quantity { get; set; }
|
||||
public DateTimeOffset PurchasedAtUtc { get; set; }
|
||||
|
||||
public RegistryItem RegistryItem { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class PlatformOwner
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public DateTimeOffset AssignedAtUtc { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class Registry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string PublicLinkCode { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
public string? BabyName { get; set; }
|
||||
public string? HeaderContentHtml { get; set; }
|
||||
public string? ShippingAddress { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public string ThemeKey { get; set; } = "default";
|
||||
public RegistryType RegistryType { get; set; } = RegistryType.Birth;
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public ICollection<RegistryAdmin> Admins { get; set; } = [];
|
||||
public ICollection<RegistryItem> Items { get; set; } = [];
|
||||
public ICollection<RegistryVisit> Visits { get; set; } = [];
|
||||
public ICollection<RegistryAdminInvite> AdminInvites { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class RegistryAdmin
|
||||
{
|
||||
public Guid RegistryId { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public DateTimeOffset AddedAtUtc { get; set; }
|
||||
|
||||
public Registry Registry { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class RegistryAdminInvite
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RegistryId { get; set; }
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string? SentToEmail { get; set; }
|
||||
public DateTimeOffset ExpiresAtUtc { get; set; }
|
||||
public DateTimeOffset? RedeemedAtUtc { get; set; }
|
||||
|
||||
public Registry Registry { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class RegistryItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RegistryId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? PictureUrl { get; set; }
|
||||
public string? ProductUrl { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal? PriceAmount { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public int DesiredQuantity { get; set; } = 1;
|
||||
public bool ParticipationAllowed { get; set; }
|
||||
public decimal? ParticipationTargetAmount { get; set; }
|
||||
public bool CanBeSecondHand { get; set; }
|
||||
public bool IsGiven { get; set; }
|
||||
public int PurchasedQuantity { get; set; }
|
||||
public decimal MoneyFulfilledAmount { get; set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
public Registry Registry { get; set; } = null!;
|
||||
public ICollection<ItemPurchase> Purchases { get; set; } = [];
|
||||
public ICollection<ItemContribution> Contributions { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class RegistrySettings
|
||||
{
|
||||
public Guid RegistryId { get; set; }
|
||||
public string? BankAccountIban { get; set; }
|
||||
public string? BankAccountBic { get; set; }
|
||||
public string? BankAccountDisplayName { get; set; }
|
||||
|
||||
public Registry Registry { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public class RegistryVisit
|
||||
{
|
||||
public Guid RegistryId { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public DateTimeOffset LastVisitedAtUtc { get; set; }
|
||||
|
||||
public Registry Registry { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace BirthList.Domain.Entities;
|
||||
|
||||
public enum RegistryType
|
||||
{
|
||||
Birth = 1,
|
||||
Wedding = 2,
|
||||
Birthday = 3
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BirthList.Domain\BirthList.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.26" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,121 @@
|
||||
using BirthList.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BirthList.Infrastructure.Persistence;
|
||||
|
||||
public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Registry> Registries => Set<Registry>();
|
||||
public DbSet<RegistryAdmin> RegistryAdmins => Set<RegistryAdmin>();
|
||||
public DbSet<RegistryAdminInvite> RegistryAdminInvites => Set<RegistryAdminInvite>();
|
||||
public DbSet<RegistrySettings> RegistrySettings => Set<RegistrySettings>();
|
||||
public DbSet<RegistryItem> RegistryItems => Set<RegistryItem>();
|
||||
public DbSet<ItemPurchase> ItemPurchases => Set<ItemPurchase>();
|
||||
public DbSet<ItemContribution> ItemContributions => Set<ItemContribution>();
|
||||
public DbSet<RegistryVisit> RegistryVisits => Set<RegistryVisit>();
|
||||
public DbSet<PlatformOwner> PlatformOwners => Set<PlatformOwner>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Registry>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Title).HasMaxLength(250);
|
||||
entity.Property(x => x.PublicLinkCode).HasMaxLength(100);
|
||||
entity.HasIndex(x => x.PublicLinkCode).IsUnique();
|
||||
entity.Property(x => x.BabyName).HasMaxLength(100);
|
||||
entity.Property(x => x.CurrencyCode).HasMaxLength(3);
|
||||
entity.Property(x => x.ThemeKey).HasMaxLength(100);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RegistryAdmin>(entity =>
|
||||
{
|
||||
entity.HasKey(x => new { x.RegistryId, x.UserId });
|
||||
entity.Property(x => x.UserId).HasMaxLength(450);
|
||||
entity.HasOne(x => x.Registry)
|
||||
.WithMany(x => x.Admins)
|
||||
.HasForeignKey(x => x.RegistryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RegistryAdminInvite>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Token).HasMaxLength(120);
|
||||
entity.HasIndex(x => x.Token).IsUnique();
|
||||
entity.Property(x => x.SentToEmail).HasMaxLength(320);
|
||||
entity.HasOne(x => x.Registry)
|
||||
.WithMany(x => x.AdminInvites)
|
||||
.HasForeignKey(x => x.RegistryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RegistrySettings>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.RegistryId);
|
||||
entity.Property(x => x.BankAccountIban).HasMaxLength(34);
|
||||
entity.Property(x => x.BankAccountBic).HasMaxLength(11);
|
||||
entity.Property(x => x.BankAccountDisplayName).HasMaxLength(120);
|
||||
entity.HasOne(x => x.Registry)
|
||||
.WithOne()
|
||||
.HasForeignKey<RegistrySettings>(x => x.RegistryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RegistryItem>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Name).HasMaxLength(300);
|
||||
entity.Property(x => x.PictureUrl).HasMaxLength(2048);
|
||||
entity.Property(x => x.ProductUrl).HasMaxLength(2048);
|
||||
entity.Property(x => x.CurrencyCode).HasMaxLength(3);
|
||||
entity.Property(x => x.PriceAmount).HasPrecision(18, 2);
|
||||
entity.Property(x => x.ParticipationTargetAmount).HasPrecision(18, 2);
|
||||
entity.Property(x => x.MoneyFulfilledAmount).HasPrecision(18, 2);
|
||||
entity.HasOne(x => x.Registry)
|
||||
.WithMany(x => x.Items)
|
||||
.HasForeignKey(x => x.RegistryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ItemPurchase>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.UserId).HasMaxLength(450);
|
||||
entity.HasOne(x => x.RegistryItem)
|
||||
.WithMany(x => x.Purchases)
|
||||
.HasForeignKey(x => x.RegistryItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ItemContribution>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.UserId).HasMaxLength(450);
|
||||
entity.Property(x => x.CurrencyCode).HasMaxLength(3);
|
||||
entity.Property(x => x.Amount).HasPrecision(18, 2);
|
||||
entity.Property(x => x.TransferMessage).HasMaxLength(500);
|
||||
entity.HasOne(x => x.RegistryItem)
|
||||
.WithMany(x => x.Contributions)
|
||||
.HasForeignKey(x => x.RegistryItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RegistryVisit>(entity =>
|
||||
{
|
||||
entity.HasKey(x => new { x.RegistryId, x.UserId });
|
||||
entity.Property(x => x.UserId).HasMaxLength(450);
|
||||
entity.HasOne(x => x.Registry)
|
||||
.WithMany(x => x.Visits)
|
||||
.HasForeignKey(x => x.RegistryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlatformOwner>(entity =>
|
||||
{
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.UserId).HasMaxLength(450);
|
||||
entity.HasIndex(x => x.UserId).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using BirthList.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BirthList.Web.Authorization;
|
||||
|
||||
internal sealed class RegistryAuthorizationService(RegistryDbContext registryDbContext)
|
||||
{
|
||||
public async Task<bool> IsRegistryAdminAsync(Guid registryId, string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
var isPlatformOwner = await registryDbContext.PlatformOwners
|
||||
.AnyAsync(x => x.UserId == userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (isPlatformOwner)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return await registryDbContext.RegistryAdmins
|
||||
.AnyAsync(x => x.RegistryId == registryId && x.UserId == userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>aspnet-BirthList.Web-ce1c981e-2f7f-43d4-aba3-149fcf3a966a</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Data\app.db" CopyToOutputDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazored.TextEditor" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.26" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.26" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.26" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.26" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.26" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.26" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.26">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BirthList.Infrastructure\BirthList.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\BirthList.Domain\BirthList.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using BirthList.Web.Components.Account.Pages;
|
||||
using BirthList.Web.Components.Account.Pages.Manage;
|
||||
using BirthList.Web.Data;
|
||||
|
||||
namespace Microsoft.AspNetCore.Routing;
|
||||
|
||||
internal static class IdentityComponentsEndpointRouteBuilderExtensions
|
||||
{
|
||||
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
|
||||
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
|
||||
var accountGroup = endpoints.MapGroup("/Account");
|
||||
|
||||
accountGroup.MapPost("/PerformExternalLogin", (
|
||||
HttpContext context,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string provider,
|
||||
[FromForm] string returnUrl) =>
|
||||
{
|
||||
IEnumerable<KeyValuePair<string, StringValues>> query = [
|
||||
new("ReturnUrl", returnUrl),
|
||||
new("Action", ExternalLogin.LoginCallbackAction)];
|
||||
|
||||
var redirectUrl = UriHelper.BuildRelative(
|
||||
context.Request.PathBase,
|
||||
"/Account/ExternalLogin",
|
||||
QueryString.Create(query));
|
||||
|
||||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
||||
return TypedResults.Challenge(properties, [provider]);
|
||||
});
|
||||
|
||||
accountGroup.MapPost("/Logout", async (
|
||||
ClaimsPrincipal user,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string returnUrl) =>
|
||||
{
|
||||
await signInManager.SignOutAsync();
|
||||
return TypedResults.LocalRedirect($"~/{returnUrl}");
|
||||
});
|
||||
|
||||
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
|
||||
|
||||
manageGroup.MapPost("/LinkExternalLogin", async (
|
||||
HttpContext context,
|
||||
[FromServices] SignInManager<ApplicationUser> signInManager,
|
||||
[FromForm] string provider) =>
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await context.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
|
||||
var redirectUrl = UriHelper.BuildRelative(
|
||||
context.Request.PathBase,
|
||||
"/Account/Manage/ExternalLogins",
|
||||
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
|
||||
|
||||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User));
|
||||
return TypedResults.Challenge(properties, [provider]);
|
||||
});
|
||||
|
||||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
|
||||
|
||||
manageGroup.MapPost("/DownloadPersonalData", async (
|
||||
HttpContext context,
|
||||
[FromServices] UserManager<ApplicationUser> userManager,
|
||||
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
|
||||
{
|
||||
var user = await userManager.GetUserAsync(context.User);
|
||||
if (user is null)
|
||||
{
|
||||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
|
||||
}
|
||||
|
||||
var userId = await userManager.GetUserIdAsync(user);
|
||||
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
|
||||
|
||||
// Only include personal data for download
|
||||
var personalData = new Dictionary<string, string>();
|
||||
var personalDataProps = typeof(ApplicationUser).GetProperties().Where(
|
||||
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
|
||||
foreach (var p in personalDataProps)
|
||||
{
|
||||
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
|
||||
}
|
||||
|
||||
var logins = await userManager.GetLoginsAsync(user);
|
||||
foreach (var l in logins)
|
||||
{
|
||||
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
|
||||
}
|
||||
|
||||
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
|
||||
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
|
||||
|
||||
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
|
||||
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
|
||||
});
|
||||
|
||||
return accountGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
||||
using BirthList.Web.Data;
|
||||
|
||||
namespace BirthList.Web.Components.Account;
|
||||
|
||||
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
|
||||
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
|
||||
{
|
||||
private readonly IEmailSender emailSender = new NoOpEmailSender();
|
||||
|
||||
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
|
||||
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
|
||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
|
||||
|
||||
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
|
||||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Account;
|
||||
|
||||
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
|
||||
{
|
||||
public const string StatusCookieName = "Identity.StatusMessage";
|
||||
|
||||
private static readonly CookieBuilder StatusCookieBuilder = new()
|
||||
{
|
||||
SameSite = SameSiteMode.Strict,
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
MaxAge = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectTo(string? uri)
|
||||
{
|
||||
uri ??= "";
|
||||
|
||||
// Prevent open redirects.
|
||||
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
|
||||
{
|
||||
uri = navigationManager.ToBaseRelativePath(uri);
|
||||
}
|
||||
|
||||
// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect.
|
||||
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown.
|
||||
navigationManager.NavigateTo(uri);
|
||||
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering.");
|
||||
}
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
|
||||
{
|
||||
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
|
||||
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
|
||||
RedirectTo(newUri);
|
||||
}
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectToWithStatus(string uri, string message, HttpContext context)
|
||||
{
|
||||
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
|
||||
RedirectTo(uri);
|
||||
}
|
||||
|
||||
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
|
||||
|
||||
[DoesNotReturn]
|
||||
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
|
||||
=> RedirectToWithStatus(CurrentPath, message, context);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using BirthList.Web.Data;
|
||||
|
||||
namespace BirthList.Web.Components.Account;
|
||||
|
||||
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
|
||||
// every 30 minutes an interactive circuit is connected.
|
||||
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
|
||||
ILoggerFactory loggerFactory,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<IdentityOptions> options)
|
||||
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
|
||||
{
|
||||
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
|
||||
|
||||
protected override async Task<bool> ValidateAuthenticationStateAsync(
|
||||
AuthenticationState authenticationState, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the user manager from a new scope to ensure it fetches fresh data
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(principal);
|
||||
if (user is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else if (!userManager.SupportsUserSecurityStamp)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
|
||||
var userStamp = await userManager.GetSecurityStampAsync(user);
|
||||
return principalStamp == userStamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using BirthList.Web.Data;
|
||||
|
||||
namespace BirthList.Web.Components.Account;
|
||||
|
||||
internal sealed class IdentityUserAccessor(UserManager<ApplicationUser> userManager, IdentityRedirectManager redirectManager)
|
||||
{
|
||||
public async Task<ApplicationUser> GetRequiredUserAsync(HttpContext context)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(context.User);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
redirectManager.RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/AccessDenied"
|
||||
|
||||
<PageTitle>Access denied</PageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Access denied</h1>
|
||||
<p class="text-danger">You do not have access to this resource.</p>
|
||||
</header>
|
||||
@@ -0,0 +1,48 @@
|
||||
@page "/Account/ConfirmEmail"
|
||||
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Confirm email</PageTitle>
|
||||
|
||||
<h1>Confirm email</h1>
|
||||
<StatusMessage Message="@statusMessage" />
|
||||
|
||||
@code {
|
||||
private string? statusMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? UserId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Code { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (UserId is null || Code is null)
|
||||
{
|
||||
RedirectManager.RedirectTo("");
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByIdAsync(UserId);
|
||||
if (user is null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
statusMessage = $"Error loading user with ID {UserId}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
|
||||
var result = await UserManager.ConfirmEmailAsync(user, code);
|
||||
statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@page "/Account/ConfirmEmailChange"
|
||||
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Confirm email change</PageTitle>
|
||||
|
||||
<h1>Confirm email change</h1>
|
||||
|
||||
<StatusMessage Message="@message" />
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? UserId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Email { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Code { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (UserId is null || Email is null || Code is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus(
|
||||
"Account/Login", "Error: Invalid email change confirmation link.", HttpContext);
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByIdAsync(UserId);
|
||||
if (user is null)
|
||||
{
|
||||
message = "Unable to find user with Id '{userId}'";
|
||||
return;
|
||||
}
|
||||
|
||||
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
|
||||
var result = await UserManager.ChangeEmailAsync(user, Email, code);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
message = "Error changing email.";
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
var setUserNameResult = await UserManager.SetUserNameAsync(user, Email);
|
||||
if (!setUserNameResult.Succeeded)
|
||||
{
|
||||
message = "Error changing user name.";
|
||||
return;
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
message = "Thank you for confirming your email change.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
@page "/Account/ExternalLogin"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Security.Claims
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IUserStore<ApplicationUser> UserStore
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<ExternalLogin> Logger
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<StatusMessage Message="@message" />
|
||||
<h1>Register</h1>
|
||||
<h2>Associate your @ProviderDisplayName account.</h2>
|
||||
<hr />
|
||||
|
||||
<div class="alert alert-info">
|
||||
You've successfully authenticated with <strong>@ProviderDisplayName</strong>.
|
||||
Please enter an email address for this site below and click the Register button to finish
|
||||
logging in.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" OnValidSubmit="OnValidSubmitAsync" FormName="confirmation" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email." />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
public const string LoginCallbackAction = "LoginCallback";
|
||||
|
||||
private string? message;
|
||||
private ExternalLoginInfo externalLoginInfo = default!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? RemoteError { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Action { get; set; }
|
||||
|
||||
private string? ProviderDisplayName => externalLoginInfo.ProviderDisplayName;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (RemoteError is not null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", $"Error from external provider: {RemoteError}", HttpContext);
|
||||
}
|
||||
|
||||
var info = await SignInManager.GetExternalLoginInfoAsync();
|
||||
if (info is null)
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Login", "Error loading external login information.", HttpContext);
|
||||
}
|
||||
|
||||
externalLoginInfo = info;
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method))
|
||||
{
|
||||
if (Action == LoginCallbackAction)
|
||||
{
|
||||
await OnLoginCallbackAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// We should only reach this page via the login callback, so redirect back to
|
||||
// the login page if we get here some other way.
|
||||
RedirectManager.RedirectTo("Account/Login");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnLoginCallbackAsync()
|
||||
{
|
||||
// Sign in the user with this external login provider if the user already has a login.
|
||||
var result = await SignInManager.ExternalLoginSignInAsync(
|
||||
externalLoginInfo.LoginProvider,
|
||||
externalLoginInfo.ProviderKey,
|
||||
isPersistent: false,
|
||||
bypassTwoFactor: true);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation(
|
||||
"{Name} logged in with {LoginProvider} provider.",
|
||||
externalLoginInfo.Principal.Identity?.Name,
|
||||
externalLoginInfo.LoginProvider);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
|
||||
// If the user does not have an account, then ask the user to create an account.
|
||||
if (externalLoginInfo.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
|
||||
{
|
||||
Input.Email = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var emailStore = GetEmailStore();
|
||||
var user = CreateUser();
|
||||
|
||||
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
||||
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
|
||||
|
||||
var result = await UserManager.CreateAsync(user);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
result = await UserManager.AddLoginAsync(user, externalLoginInfo);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User created an account using {Name} provider.", externalLoginInfo.LoginProvider);
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
// 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)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/RegisterConfirmation", new() { ["email"] = Input.Email });
|
||||
}
|
||||
|
||||
await SignInManager.SignInAsync(user, isPersistent: false, externalLoginInfo.LoginProvider);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
}
|
||||
|
||||
message = $"Error: {string.Join(",", result.Errors.Select(error => error.Description))}";
|
||||
}
|
||||
|
||||
private ApplicationUser CreateUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Activator.CreateInstance<ApplicationUser>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
|
||||
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor");
|
||||
}
|
||||
}
|
||||
|
||||
private IUserEmailStore<ApplicationUser> GetEmailStore()
|
||||
{
|
||||
if (!UserManager.SupportsUserEmail)
|
||||
{
|
||||
throw new NotSupportedException("The default UI requires a user store with email support.");
|
||||
}
|
||||
return (IUserEmailStore<ApplicationUser>)UserStore;
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@page "/Account/ForgotPassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Forgot your password?</PageTitle>
|
||||
|
||||
<h1>Forgot your password?</h1>
|
||||
<h2>Enter your email.</h2>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="forgot-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset password</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var 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
|
||||
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
|
||||
}
|
||||
|
||||
// For more information on how to enable account confirmation and password reset please
|
||||
// visit https://go.microsoft.com/fwlink/?LinkID=532713
|
||||
var code = await UserManager.GeneratePasswordResetTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ResetPassword").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["code"] = code });
|
||||
|
||||
await EmailSender.SendPasswordResetLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
RedirectManager.RedirectTo("Account/ForgotPasswordConfirmation");
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/ForgotPasswordConfirmation"
|
||||
|
||||
<PageTitle>Forgot password confirmation</PageTitle>
|
||||
|
||||
<h1>Forgot password confirmation</h1>
|
||||
<p>
|
||||
Please check your email to reset your password.
|
||||
</p>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/InvalidPasswordReset"
|
||||
|
||||
<PageTitle>Invalid password reset</PageTitle>
|
||||
|
||||
<h1>Invalid password reset</h1>
|
||||
<p>
|
||||
The password reset link is invalid.
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/Account/InvalidUser"
|
||||
|
||||
<PageTitle>Invalid user</PageTitle>
|
||||
|
||||
<h3>Invalid user</h3>
|
||||
|
||||
<StatusMessage />
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/Account/Lockout"
|
||||
|
||||
<PageTitle>Locked out</PageTitle>
|
||||
|
||||
<header>
|
||||
<h1 class="text-danger">Locked out</h1>
|
||||
<p class="text-danger">This account has been locked out, please try again later.</p>
|
||||
</header>
|
||||
@@ -0,0 +1,128 @@
|
||||
@page "/Account/Login"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject ILogger<Login> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Log in</PageTitle>
|
||||
|
||||
<h1>Log in</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<section>
|
||||
<StatusMessage Message="@errorMessage" />
|
||||
<EditForm Model="Input" method="post" OnValidSubmit="LoginUser" FormName="login">
|
||||
<DataAnnotationsValidator />
|
||||
<h2>Use a local account to log in.</h2>
|
||||
<hr />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="checkbox mb-3">
|
||||
<label class="form-label">
|
||||
<InputCheckbox @bind-Value="Input.RememberMe" class="darker-border-checkbox form-check-input" />
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<a href="Account/ForgotPassword">Forgot your password?</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="@(NavigationManager.GetUriWithQueryParameters("Account/Register", new Dictionary<string, object?> { ["ReturnUrl"] = ReturnUrl }))">Register as a new user</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="Account/ResendEmailConfirmation">Resend email confirmation</a>
|
||||
</p>
|
||||
</div>
|
||||
</EditForm>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-md-6 col-md-offset-2">
|
||||
<section>
|
||||
<h3>Use another service to log in.</h3>
|
||||
<hr />
|
||||
<ExternalLoginPicker />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method))
|
||||
{
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoginUser()
|
||||
{
|
||||
// This doesn't count login failures towards account lockout
|
||||
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
|
||||
var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User logged in.");
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.RequiresTwoFactor)
|
||||
{
|
||||
RedirectManager.RedirectTo(
|
||||
"Account/LoginWith2fa",
|
||||
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Error: Invalid login attempt.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Display(Name = "Remember me?")]
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
@page "/Account/LoginWith2fa"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<LoginWith2fa> Logger
|
||||
|
||||
<PageTitle>Two-factor authentication</PageTitle>
|
||||
|
||||
<h1>Two-factor authentication</h1>
|
||||
<hr />
|
||||
<StatusMessage Message="@message" />
|
||||
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="login-with-2fa" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<input type="hidden" name="ReturnUrl" value="@ReturnUrl" />
|
||||
<input type="hidden" name="RememberMe" value="@RememberMe" />
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.TwoFactorCode" class="form-control" autocomplete="off" />
|
||||
<label for="two-factor-code" class="form-label">Authenticator code</label>
|
||||
<ValidationMessage For="() => Input.TwoFactorCode" class="text-danger" />
|
||||
</div>
|
||||
<div class="checkbox mb-3">
|
||||
<label for="remember-machine" class="form-label">
|
||||
<InputCheckbox @bind-Value="Input.RememberMachine" />
|
||||
Remember this machine
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Don't have access to your authenticator device? You can
|
||||
<a href="Account/LoginWithRecoveryCode?ReturnUrl=@ReturnUrl">log in with a recovery code</a>.
|
||||
</p>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private bool RememberMe { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
|
||||
throw new InvalidOperationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var authenticatorCode = Input.TwoFactorCode!.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
var result = await SignInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, RememberMe, Input.RememberMachine);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", userId);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User with ID '{UserId}' account locked out.", userId);
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", userId);
|
||||
message = "Error: Invalid authenticator code.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Authenticator code")]
|
||||
public string? TwoFactorCode { get; set; }
|
||||
|
||||
[Display(Name = "Remember this machine")]
|
||||
public bool RememberMachine { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@page "/Account/LoginWithRecoveryCode"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<LoginWithRecoveryCode> Logger
|
||||
|
||||
<PageTitle>Recovery code verification</PageTitle>
|
||||
|
||||
<h1>Recovery code verification</h1>
|
||||
<hr />
|
||||
<StatusMessage Message="@message" />
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="login-with-recovery-code" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
|
||||
<label for="recovery-code" class="form-label">Recovery Code</label>
|
||||
<ValidationMessage For="() => Input.RecoveryCode" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Ensure the user has gone through the username & password screen first
|
||||
user = await SignInManager.GetTwoFactorAuthenticationUserAsync() ??
|
||||
throw new InvalidOperationException("Unable to load two-factor authentication user.");
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
|
||||
|
||||
var result = await SignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
Logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", userId);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
else if (result.IsLockedOut)
|
||||
{
|
||||
Logger.LogWarning("User account locked out.");
|
||||
RedirectManager.RedirectTo("Account/Lockout");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", userId);
|
||||
message = "Error: Invalid recovery code entered.";
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Recovery Code")]
|
||||
public string RecoveryCode { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
@page "/Account/Manage/ChangePassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<ChangePassword> Logger
|
||||
|
||||
<PageTitle>Change password</PageTitle>
|
||||
|
||||
<h3>Change password</h3>
|
||||
<StatusMessage Message="@message" />
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<EditForm Model="Input" FormName="change-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password." />
|
||||
<label for="old-password" class="form-label">Old password</label>
|
||||
<ValidationMessage For="() => Input.OldPassword" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password." />
|
||||
<label for="new-password" class="form-label">New password</label>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password." />
|
||||
<label for="confirm-password" class="form-label">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
private bool hasPassword;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
hasPassword = await UserManager.HasPasswordAsync(user);
|
||||
if (!hasPassword)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/Manage/SetPassword");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var changePasswordResult = await UserManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
|
||||
if (!changePasswordResult.Succeeded)
|
||||
{
|
||||
message = $"Error: {string.Join(",", changePasswordResult.Errors.Select(error => error.Description))}";
|
||||
return;
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
Logger.LogInformation("User changed their password successfully.");
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Your password has been changed", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[DataType(DataType.Password)]
|
||||
[Display(Name = "Current password")]
|
||||
public string OldPassword { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@page "/Account/Manage/DeletePersonalData"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<DeletePersonalData> Logger
|
||||
|
||||
<PageTitle>Delete Personal Data</PageTitle>
|
||||
|
||||
<StatusMessage Message="@message" />
|
||||
|
||||
<h3>Delete Personal Data</h3>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EditForm Model="Input" FormName="delete-user" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
@if (requirePassword)
|
||||
{
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
}
|
||||
<button class="w-100 btn btn-lg btn-danger" type="submit">Delete data and close my account</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
private bool requirePassword;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Input ??= new();
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
requirePassword = await UserManager.HasPasswordAsync(user);
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (requirePassword && !await UserManager.CheckPasswordAsync(user, Input.Password))
|
||||
{
|
||||
message = "Error: Incorrect password.";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await UserManager.DeleteAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected error occurred deleting user.");
|
||||
}
|
||||
|
||||
await SignInManager.SignOutAsync();
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
|
||||
|
||||
RedirectManager.RedirectToCurrentPage();
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[DataType(DataType.Password)]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@page "/Account/Manage/Disable2fa"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<Disable2fa> Logger
|
||||
|
||||
<PageTitle>Disable two-factor authentication (2FA)</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Disable two-factor authentication (2FA)</h3>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<strong>This action only disables 2FA.</strong>
|
||||
</p>
|
||||
<p>
|
||||
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 <a href="Account/Manage/ResetAuthenticator">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form @formname="disable-2fa" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-danger" type="submit">Disable 2FA</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ApplicationUser user = default!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method) && !await UserManager.GetTwoFactorEnabledAsync(user))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot disable 2FA for user as it's not currently enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var disable2faResult = await UserManager.SetTwoFactorEnabledAsync(user, false);
|
||||
if (!disable2faResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unexpected error occurred disabling 2FA.");
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", userId);
|
||||
RedirectManager.RedirectToWithStatus(
|
||||
"Account/Manage/TwoFactorAuthentication",
|
||||
"2fa has been disabled. You can reenable 2fa when you setup an authenticator app",
|
||||
HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
@page "/Account/Manage/Email"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>Manage email</PageTitle>
|
||||
|
||||
<h3>Manage email</h3>
|
||||
|
||||
<StatusMessage Message="@message"/>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form @onsubmit="OnSendEmailVerificationAsync" @formname="send-verification" id="send-verification-form" method="post">
|
||||
<AntiforgeryToken />
|
||||
</form>
|
||||
<EditForm Model="Input" FormName="change-email" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
@if (isEmailConfirmed)
|
||||
{
|
||||
<div class="form-floating mb-3 input-group">
|
||||
<input type="text" value="@email" class="form-control" placeholder="Please enter your email." disabled />
|
||||
<div class="input-group-append">
|
||||
<span class="h-100 input-group-text text-success font-weight-bold">✓</span>
|
||||
</div>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" value="@email" class="form-control" placeholder="Please enter your email." disabled />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<button type="submit" class="btn btn-link" form="send-verification-form">Send verification email</button>
|
||||
</div>
|
||||
}
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Please enter new email." />
|
||||
<label for="new-email" class="form-label">New email</label>
|
||||
<ValidationMessage For="() => Input.NewEmail" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Change email</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
private string? email;
|
||||
private bool isEmailConfirmed;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm(FormName = "change-email")]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
email = await UserManager.GetEmailAsync(user);
|
||||
isEmailConfirmed = await UserManager.IsEmailConfirmedAsync(user);
|
||||
|
||||
Input.NewEmail ??= email;
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (Input.NewEmail is null || Input.NewEmail == email)
|
||||
{
|
||||
message = "Your email is unchanged.";
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmailChange").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["email"] = Input.NewEmail, ["code"] = code });
|
||||
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.NewEmail, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
message = "Confirmation link to change email sent. Please check your email.";
|
||||
}
|
||||
|
||||
private async Task OnSendEmailVerificationAsync()
|
||||
{
|
||||
if (email is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
|
||||
|
||||
await EmailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
message = "Verification email sent. Please check your email.";
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[Display(Name = "New email")]
|
||||
public string? NewEmail { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
@page "/Account/Manage/EnableAuthenticator"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject UrlEncoder UrlEncoder
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<EnableAuthenticator> Logger
|
||||
|
||||
<PageTitle>Configure authenticator app</PageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()" StatusMessage="@message" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusMessage Message="@message" />
|
||||
<h3>Configure authenticator app</h3>
|
||||
<div>
|
||||
<p>To use an authenticator app go through the following steps:</p>
|
||||
<ol class="list">
|
||||
<li>
|
||||
<p>
|
||||
Download a two-factor authenticator app like Microsoft Authenticator for
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
|
||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
|
||||
Google Authenticator for
|
||||
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en">Android</a> and
|
||||
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">iOS</a>.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p>Scan the QR Code or enter this key <kbd>@sharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
||||
<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
|
||||
<div></div>
|
||||
<div data-url="@authenticatorUri"></div>
|
||||
</li>
|
||||
<li>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<EditForm Model="Input" FormName="send-code" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Code" class="form-control" autocomplete="off" placeholder="Please enter the code." />
|
||||
<label for="code" class="control-label form-label">Verification Code</label>
|
||||
<ValidationMessage For="() => Input.Code" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Verify</button>
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
|
||||
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
private string? sharedKey;
|
||||
private string? authenticatorUri;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
|
||||
await LoadSharedKeyAndQrCodeUriAsync(user);
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
// Strip spaces and hyphens
|
||||
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
|
||||
|
||||
var is2faTokenValid = await UserManager.VerifyTwoFactorTokenAsync(
|
||||
user, UserManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
|
||||
|
||||
if (!is2faTokenValid)
|
||||
{
|
||||
message = "Error: Verification code is invalid.";
|
||||
return;
|
||||
}
|
||||
|
||||
await UserManager.SetTwoFactorEnabledAsync(user, true);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
|
||||
|
||||
message = "Your authenticator app has been verified.";
|
||||
|
||||
if (await UserManager.CountRecoveryCodesAsync(user) == 0)
|
||||
{
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||
}
|
||||
else
|
||||
{
|
||||
RedirectManager.RedirectToWithStatus("Account/Manage/TwoFactorAuthentication", message, HttpContext);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user)
|
||||
{
|
||||
// Load the authenticator key & QR code URI to display on the form
|
||||
var unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
if (string.IsNullOrEmpty(unformattedKey))
|
||||
{
|
||||
await UserManager.ResetAuthenticatorKeyAsync(user);
|
||||
unformattedKey = await UserManager.GetAuthenticatorKeyAsync(user);
|
||||
}
|
||||
|
||||
sharedKey = FormatKey(unformattedKey!);
|
||||
|
||||
var email = await UserManager.GetEmailAsync(user);
|
||||
authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!);
|
||||
}
|
||||
|
||||
private string FormatKey(string unformattedKey)
|
||||
{
|
||||
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("Microsoft.AspNetCore.Identity.UI"),
|
||||
UrlEncoder.Encode(email),
|
||||
unformattedKey);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[DataType(DataType.Text)]
|
||||
[Display(Name = "Verification Code")]
|
||||
public string Code { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
@page "/Account/Manage/ExternalLogins"
|
||||
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IUserStore<ApplicationUser> UserStore
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Manage your external logins</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
@if (currentLogins?.Count > 0)
|
||||
{
|
||||
<h3>Registered Logins</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@foreach (var login in currentLogins)
|
||||
{
|
||||
<tr>
|
||||
<td>@login.ProviderDisplayName</td>
|
||||
<td>
|
||||
@if (showRemoveButton)
|
||||
{
|
||||
<form @formname="@($"remove-login-{login.LoginProvider}")" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<div>
|
||||
<input type="hidden" name="@nameof(LoginProvider)" value="@login.LoginProvider" />
|
||||
<input type="hidden" name="@nameof(ProviderKey)" value="@login.ProviderKey" />
|
||||
<button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
@:
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (otherLogins?.Count > 0)
|
||||
{
|
||||
<h4>Add another service to log in.</h4>
|
||||
<hr />
|
||||
<form class="form-horizontal" action="Account/Manage/LinkExternalLogin" method="post">
|
||||
<AntiforgeryToken />
|
||||
<div>
|
||||
<p>
|
||||
@foreach (var provider in otherLogins)
|
||||
{
|
||||
<button type="submit" class="btn btn-primary" name="Provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">
|
||||
@provider.DisplayName
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
public const string LinkLoginCallbackAction = "LinkLoginCallback";
|
||||
|
||||
private ApplicationUser user = default!;
|
||||
private IList<UserLoginInfo>? currentLogins;
|
||||
private IList<AuthenticationScheme>? otherLogins;
|
||||
private bool showRemoveButton;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private string? LoginProvider { get; set; }
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private string? ProviderKey { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Action { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
currentLogins = await UserManager.GetLoginsAsync(user);
|
||||
otherLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync())
|
||||
.Where(auth => currentLogins.All(ul => auth.Name != ul.LoginProvider))
|
||||
.ToList();
|
||||
|
||||
string? passwordHash = null;
|
||||
if (UserStore is IUserPasswordStore<ApplicationUser> userPasswordStore)
|
||||
{
|
||||
passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
showRemoveButton = passwordHash is not null || currentLogins.Count > 1;
|
||||
|
||||
if (HttpMethods.IsGet(HttpContext.Request.Method) && Action == LinkLoginCallbackAction)
|
||||
{
|
||||
await OnGetLinkLoginCallbackAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var result = await UserManager.RemoveLoginAsync(user, LoginProvider!, ProviderKey!);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not removed.", HttpContext);
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("The external login was removed.", HttpContext);
|
||||
}
|
||||
|
||||
private async Task OnGetLinkLoginCallbackAsync()
|
||||
{
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var info = await SignInManager.GetExternalLoginInfoAsync(userId);
|
||||
if (info is null)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: Could not load external login info.", HttpContext);
|
||||
}
|
||||
|
||||
var result = await UserManager.AddLoginAsync(user, info);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: The external login was not added. External logins can only be associated with one account.", HttpContext);
|
||||
}
|
||||
|
||||
// Clear the existing external cookie to ensure a clean login process
|
||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("The external login was added.", HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@page "/Account/Manage/GenerateRecoveryCodes"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<GenerateRecoveryCodes> Logger
|
||||
|
||||
<PageTitle>Generate two-factor authentication (2FA) recovery codes</PageTitle>
|
||||
|
||||
@if (recoveryCodes is not null)
|
||||
{
|
||||
<ShowRecoveryCodes RecoveryCodes="recoveryCodes.ToArray()" StatusMessage="@message" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<h3>Generate two-factor authentication (2FA) recovery codes</h3>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
||||
<strong>Put these codes in a safe place.</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
<p>
|
||||
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 <a href="Account/Manage/ResetAuthenticator">reset your authenticator keys.</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form @formname="generate-recovery-codes" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
private IEnumerable<string>? recoveryCodes;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
|
||||
var isTwoFactorEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
|
||||
if (!isTwoFactorEnabled)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot generate recovery codes for user because they do not have 2FA enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
recoveryCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||
message = "You have generated new recovery codes.";
|
||||
|
||||
Logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@page "/Account/Manage"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Profile</PageTitle>
|
||||
|
||||
<h3>Profile</h3>
|
||||
<StatusMessage />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<EditForm Model="Input" FormName="profile" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" value="@username" class="form-control" placeholder="Please choose your username." disabled />
|
||||
<label for="username" class="form-label">Username</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number." />
|
||||
<label for="phone-number" class="form-label">Phone number</label>
|
||||
<ValidationMessage For="() => Input.PhoneNumber" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Save</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private ApplicationUser user = default!;
|
||||
private string? username;
|
||||
private string? phoneNumber;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
username = await UserManager.GetUserNameAsync(user);
|
||||
phoneNumber = await UserManager.GetPhoneNumberAsync(user);
|
||||
|
||||
Input.PhoneNumber ??= phoneNumber;
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
if (Input.PhoneNumber != phoneNumber)
|
||||
{
|
||||
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
|
||||
if (!setPhoneResult.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext);
|
||||
}
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Your profile has been updated", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Phone]
|
||||
[Display(Name = "Phone number")]
|
||||
public string? PhoneNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@page "/Account/Manage/PersonalData"
|
||||
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
|
||||
<PageTitle>Personal Data</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Personal Data</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
|
||||
<p>
|
||||
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
|
||||
</p>
|
||||
<form action="Account/Manage/DownloadPersonalData" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-primary" type="submit">Download</button>
|
||||
</form>
|
||||
<p>
|
||||
<a href="Account/Manage/DeletePersonalData" class="btn btn-danger">Delete</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_ = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@page "/Account/Manage/ResetAuthenticator"
|
||||
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject ILogger<ResetAuthenticator> Logger
|
||||
|
||||
<PageTitle>Reset authenticator key</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Reset authenticator key</h3>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
||||
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<form @formname="reset-authenticator" @onsubmit="OnSubmitAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-danger" type="submit">Reset authenticator key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private async Task OnSubmitAsync()
|
||||
{
|
||||
var user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
await UserManager.SetTwoFactorEnabledAsync(user, false);
|
||||
await UserManager.ResetAuthenticatorKeyAsync(user);
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
|
||||
RedirectManager.RedirectToWithStatus(
|
||||
"Account/Manage/EnableAuthenticator",
|
||||
"Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.",
|
||||
HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@page "/Account/Manage/SetPassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Set password</PageTitle>
|
||||
|
||||
<h3>Set your password</h3>
|
||||
<StatusMessage Message="@message" />
|
||||
<p class="text-info">
|
||||
You do not have a local username/password for this site. Add a local
|
||||
account so you can log in without an external login.
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<EditForm Model="Input" FormName="set-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Please enter your new password." />
|
||||
<label for="new-password" class="form-label">New password</label>
|
||||
<ValidationMessage For="() => Input.NewPassword" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password." />
|
||||
<label for="confirm-password" class="form-label">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
private ApplicationUser user = default!;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
|
||||
var hasPassword = await UserManager.HasPasswordAsync(user);
|
||||
if (hasPassword)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/Manage/ChangePassword");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var addPasswordResult = await UserManager.AddPasswordAsync(user, Input.NewPassword!);
|
||||
if (!addPasswordResult.Succeeded)
|
||||
{
|
||||
message = $"Error: {string.Join(",", addPasswordResult.Errors.Select(error => error.Description))}";
|
||||
return;
|
||||
}
|
||||
|
||||
await SignInManager.RefreshSignInAsync(user);
|
||||
RedirectManager.RedirectToCurrentPageWithStatus("Your password has been set.", HttpContext);
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
@page "/Account/Manage/TwoFactorAuthentication"
|
||||
|
||||
@using Microsoft.AspNetCore.Http.Features
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityUserAccessor UserAccessor
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Two-factor authentication (2FA)</PageTitle>
|
||||
|
||||
<StatusMessage />
|
||||
<h3>Two-factor authentication (2FA)</h3>
|
||||
@if (canTrack)
|
||||
{
|
||||
if (is2faEnabled)
|
||||
{
|
||||
if (recoveryCodesLeft == 0)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>You have no recovery codes left.</strong>
|
||||
<p>You must <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft == 1)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>You have 1 recovery code left.</strong>
|
||||
<p>You can <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCodesLeft <= 3)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<strong>You have @recoveryCodesLeft recovery codes left.</strong>
|
||||
<p>You should <a href="Account/Manage/GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (isMachineRemembered)
|
||||
{
|
||||
<form style="display: inline-block" @formname="forget-browser" @onsubmit="OnSubmitForgetBrowserAsync" method="post">
|
||||
<AntiforgeryToken />
|
||||
<button type="submit" class="btn btn-primary">Forget this browser</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<a href="Account/Manage/Disable2fa" class="btn btn-primary">Disable 2FA</a>
|
||||
<a href="Account/Manage/GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
|
||||
}
|
||||
|
||||
<h4>Authenticator app</h4>
|
||||
@if (!hasAuthenticator)
|
||||
{
|
||||
<a href="Account/Manage/EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="Account/Manage/EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
|
||||
<a href="Account/Manage/ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>Privacy and cookie policy have not been accepted.</strong>
|
||||
<p>You must accept the policy before you can enable two factor authentication.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool canTrack;
|
||||
private bool hasAuthenticator;
|
||||
private int recoveryCodesLeft;
|
||||
private bool is2faEnabled;
|
||||
private bool isMachineRemembered;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var user = await UserAccessor.GetRequiredUserAsync(HttpContext);
|
||||
canTrack = HttpContext.Features.Get<ITrackingConsentFeature>()?.CanTrack ?? true;
|
||||
hasAuthenticator = await UserManager.GetAuthenticatorKeyAsync(user) is not null;
|
||||
is2faEnabled = await UserManager.GetTwoFactorEnabledAsync(user);
|
||||
isMachineRemembered = await SignInManager.IsTwoFactorClientRememberedAsync(user);
|
||||
recoveryCodesLeft = await UserManager.CountRecoveryCodesAsync(user);
|
||||
}
|
||||
|
||||
private async Task OnSubmitForgetBrowserAsync()
|
||||
{
|
||||
await SignInManager.ForgetTwoFactorClientAsync();
|
||||
|
||||
RedirectManager.RedirectToCurrentPageWithStatus(
|
||||
"The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.",
|
||||
HttpContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@layout ManageLayout
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@@ -0,0 +1,149 @@
|
||||
@page "/Account/Register"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
@using BirthList.Web.Services
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IUserStore<ApplicationUser> UserStore
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject OwnerBootstrapService OwnerBootstrapService
|
||||
@inject ILogger<Register> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<h1>Register</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<StatusMessage Message="@Message" />
|
||||
<EditForm Model="Input" asp-route-returnUrl="@ReturnUrl" method="post" OnValidSubmit="RegisterUser" FormName="register">
|
||||
<DataAnnotationsValidator />
|
||||
<h2>Create a new account.</h2>
|
||||
<hr />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||
<label for="email">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
||||
<label for="password">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
<div class="col-md-6 col-md-offset-2">
|
||||
<section>
|
||||
<h3>Use another service to register.</h3>
|
||||
<hr />
|
||||
<ExternalLoginPicker />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private IEnumerable<IdentityError>? identityErrors;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
|
||||
|
||||
public async Task RegisterUser(EditContext editContext)
|
||||
{
|
||||
var user = CreateUser();
|
||||
|
||||
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
||||
var emailStore = GetEmailStore();
|
||||
await emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
|
||||
var result = await UserManager.CreateAsync(user, Input.Password);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
identityErrors = result.Errors;
|
||||
return;
|
||||
}
|
||||
|
||||
await OwnerBootstrapService.EnsureFirstUserIsOwnerAsync(user, CancellationToken.None);
|
||||
|
||||
Logger.LogInformation("User created a new account with password.");
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl });
|
||||
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
if (UserManager.Options.SignIn.RequireConfirmedAccount)
|
||||
{
|
||||
RedirectManager.RedirectTo(
|
||||
"Account/RegisterConfirmation",
|
||||
new() { ["email"] = Input.Email, ["returnUrl"] = ReturnUrl });
|
||||
}
|
||||
|
||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||
RedirectManager.RedirectTo(ReturnUrl);
|
||||
}
|
||||
|
||||
private ApplicationUser CreateUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Activator.CreateInstance<ApplicationUser>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " +
|
||||
$"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor.");
|
||||
}
|
||||
}
|
||||
|
||||
private IUserEmailStore<ApplicationUser> GetEmailStore()
|
||||
{
|
||||
if (!UserManager.SupportsUserEmail)
|
||||
{
|
||||
throw new NotSupportedException("The default UI requires a user store with email support.");
|
||||
}
|
||||
return (IUserEmailStore<ApplicationUser>)UserStore;
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
[Display(Name = "Email")]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@page "/Account/RegisterConfirmation"
|
||||
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Register confirmation</PageTitle>
|
||||
|
||||
<h1>Register confirmation</h1>
|
||||
|
||||
<StatusMessage Message="@statusMessage" />
|
||||
|
||||
@if (emailConfirmationLink is not null)
|
||||
{
|
||||
<p>
|
||||
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
|
||||
Normally this would be emailed: <a href="@emailConfirmationLink">Click here to confirm your account</a>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>Please check your email to confirm your account.</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? emailConfirmationLink;
|
||||
private string? statusMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Email { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Email is null)
|
||||
{
|
||||
RedirectManager.RedirectTo("");
|
||||
}
|
||||
|
||||
var user = await UserManager.FindByEmailAsync(Email);
|
||||
if (user is null)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
statusMessage = "Error finding user for unspecified email";
|
||||
}
|
||||
else if (EmailSender is IdentityNoOpEmailSender)
|
||||
{
|
||||
// Once you add a real email sender, you should remove this code that lets you confirm the account
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
emailConfirmationLink = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code, ["returnUrl"] = ReturnUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@page "/Account/ResendEmailConfirmation"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using System.Text.Encodings.Web
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
@inject IEmailSender<ApplicationUser> EmailSender
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
<PageTitle>Resend email confirmation</PageTitle>
|
||||
|
||||
<h1>Resend email confirmation</h1>
|
||||
<h2>Enter your email.</h2>
|
||||
<hr />
|
||||
<StatusMessage Message="@message" />
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<EditForm Model="Input" FormName="resend-email-confirmation" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com" />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? message;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(Input.Email!);
|
||||
if (user is null)
|
||||
{
|
||||
message = "Verification email sent. Please check your email.";
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await UserManager.GetUserIdAsync(user);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
||||
var callbackUrl = NavigationManager.GetUriWithQueryParameters(
|
||||
NavigationManager.ToAbsoluteUri("Account/ConfirmEmail").AbsoluteUri,
|
||||
new Dictionary<string, object?> { ["userId"] = userId, ["code"] = code });
|
||||
await EmailSender.SendConfirmationLinkAsync(user, Input.Email, HtmlEncoder.Default.Encode(callbackUrl));
|
||||
|
||||
message = "Verification email sent. Please check your email.";
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@page "/Account/ResetPassword"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Text
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject UserManager<ApplicationUser> UserManager
|
||||
|
||||
<PageTitle>Reset password</PageTitle>
|
||||
|
||||
<h1>Reset password</h1>
|
||||
<h2>Reset your password.</h2>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<StatusMessage Message="@Message" />
|
||||
<EditForm Model="Input" FormName="reset-password" OnValidSubmit="OnValidSubmitAsync" method="post">
|
||||
<DataAnnotationsValidator />
|
||||
<ValidationSummary class="text-danger" role="alert" />
|
||||
|
||||
<input type="hidden" name="Input.Code" value="@Input.Code" />
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<ValidationMessage For="() => Input.Email" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<ValidationMessage For="() => Input.Password" class="text-danger" />
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<InputText type="password" @bind-Value="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
|
||||
<label for="confirm-password" class="form-label">Confirm password</label>
|
||||
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
|
||||
</div>
|
||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private IEnumerable<IdentityError>? identityErrors;
|
||||
|
||||
[SupplyParameterFromForm]
|
||||
private InputModel Input { get; set; } = new();
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? Code { get; set; }
|
||||
|
||||
private string? Message => identityErrors is null ? null : $"Error: {string.Join(", ", identityErrors.Select(error => error.Description))}";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Code is null)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/InvalidPasswordReset");
|
||||
}
|
||||
|
||||
Input.Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
|
||||
}
|
||||
|
||||
private async Task OnValidSubmitAsync()
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(Input.Email);
|
||||
if (user is null)
|
||||
{
|
||||
// Don't reveal that the user does not exist
|
||||
RedirectManager.RedirectTo("Account/ResetPasswordConfirmation");
|
||||
}
|
||||
|
||||
var result = await UserManager.ResetPasswordAsync(user, Input.Code, Input.Password);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
RedirectManager.RedirectTo("Account/ResetPasswordConfirmation");
|
||||
}
|
||||
|
||||
identityErrors = result.Errors;
|
||||
}
|
||||
|
||||
private sealed class InputModel
|
||||
{
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
||||
[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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/Account/ResetPasswordConfirmation"
|
||||
<PageTitle>Reset password confirmation</PageTitle>
|
||||
|
||||
<h1>Reset password confirmation</h1>
|
||||
<p>
|
||||
Your password has been reset. Please <a href="Account/Login">click here to log in</a>.
|
||||
</p>
|
||||
@@ -0,0 +1,2 @@
|
||||
@using BirthList.Web.Components.Account.Shared
|
||||
@layout AccountLayout
|
||||
@@ -0,0 +1,28 @@
|
||||
@inherits LayoutComponentBase
|
||||
@layout BirthList.Web.Components.Layout.MainLayout
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@if (HttpContext is null)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@Body
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (HttpContext is null)
|
||||
{
|
||||
// If this code runs, we're currently rendering in interactive mode, so there is no HttpContext.
|
||||
// The identity pages need to set cookies, so they require an HttpContext. To achieve this we
|
||||
// must transition back from interactive mode to a server-rendered page.
|
||||
NavigationManager.Refresh(forceReload: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
|
||||
@if (externalLogins.Length == 0)
|
||||
{
|
||||
<div>
|
||||
<p>
|
||||
There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
|
||||
about setting up this ASP.NET application to support logging in via external services</a>.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form class="form-horizontal" action="Account/PerformExternalLogin" method="post">
|
||||
<div>
|
||||
<AntiforgeryToken />
|
||||
<input type="hidden" name="ReturnUrl" value="@ReturnUrl" />
|
||||
<p>
|
||||
@foreach (var provider in externalLogins)
|
||||
{
|
||||
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
private AuthenticationScheme[] externalLogins = [];
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
private string? ReturnUrl { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
externalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@inherits LayoutComponentBase
|
||||
@layout AccountLayout
|
||||
|
||||
<h1>Manage your account</h1>
|
||||
|
||||
<div>
|
||||
<h2>Change your account settings</h2>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<ManageNavMenu />
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
@Body
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using BirthList.Web.Data
|
||||
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
|
||||
<ul class="nav nav-pills flex-column">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage" Match="NavLinkMatch.All">Profile</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/Email">Email</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/ChangePassword">Password</NavLink>
|
||||
</li>
|
||||
@if (hasExternalLogins)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/ExternalLogins">External logins</NavLink>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/TwoFactorAuthentication">Two-factor authentication</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="Account/Manage/PersonalData">Personal data</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@code {
|
||||
private bool hasExternalLogins;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<StatusMessage Message="@StatusMessage" />
|
||||
<h3>Recovery codes</h3>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<p>
|
||||
<strong>Put these codes in a safe place.</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
@foreach (var recoveryCode in RecoveryCodes)
|
||||
{
|
||||
<div>
|
||||
<code class="recovery-code">@recoveryCode</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string[] RecoveryCodes { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string? StatusMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@if (!string.IsNullOrEmpty(DisplayMessage))
|
||||
{
|
||||
var statusMessageClass = DisplayMessage.StartsWith("Error") ? "danger" : "success";
|
||||
<div class="alert alert-@statusMessageClass" role="alert">
|
||||
@DisplayMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? messageFromCookie;
|
||||
|
||||
[Parameter]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
private HttpContext HttpContext { get; set; } = default!;
|
||||
|
||||
private string? DisplayMessage => Message ?? messageFromCookie;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
messageFromCookie = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName];
|
||||
|
||||
if (messageFromCookie is not null)
|
||||
{
|
||||
HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="BirthList.Web.styles.css" />
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet" />
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.bubble.css" rel="stylesheet" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||
<script src="_content/Blazored.TextEditor/quill-blot-formatter.min.js"></script>
|
||||
<script src="_content/Blazored.TextEditor/Blazored-BlazorQuill.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@implements IDisposable
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">BirthList</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Account/Manage">
|
||||
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<form action="Account/Logout" method="post">
|
||||
<AntiforgeryToken />
|
||||
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
|
||||
<button type="submit" class="nav-link">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Account/Register">
|
||||
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Account/Login">
|
||||
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
|
||||
</NavLink>
|
||||
</div>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? currentUrl;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
{
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-lock-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-person-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person' viewBox='0 0 16 16'%3E%3Cpath d='M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-person-badge-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-badge' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'/%3E%3Cpath d='M4.5 0A2.5 2.5 0 0 0 2 2.5V14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2.5A2.5 2.5 0 0 0 11.5 0h-7zM3 2.5A1.5 1.5 0 0 1 4.5 1h7A1.5 1.5 0 0 1 13 2.5v10.795a4.2 4.2 0 0 0-.776-.492C11.392 12.387 10.063 12 8 12s-3.392.387-4.224.803a4.2 4.2 0 0 0-.776.492V2.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-person-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-fill' viewBox='0 0 16 16'%3E%3Cpath d='M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3Zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-arrow-bar-left-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Birth Registry</PageTitle>
|
||||
|
||||
<h1>Birth Registry</h1>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="mb-4">
|
||||
<h2>Create your registry</h2>
|
||||
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
|
||||
<DataAnnotationsValidator />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Title</label>
|
||||
<InputText class="form-control" @bind-Value="Model.Title" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Type</label>
|
||||
<InputSelect class="form-select" @bind-Value="Model.RegistryType">
|
||||
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option>
|
||||
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option>
|
||||
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Theme</label>
|
||||
<InputSelect class="form-select" @bind-Value="Model.ThemeKey">
|
||||
<option value="default">Default</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="modern">Modern</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" type="submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-3">@ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2>Registries you manage</h2>
|
||||
@if (MyRegistries.Count == 0)
|
||||
{
|
||||
<p>No registries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul>
|
||||
@foreach (var registry in MyRegistries)
|
||||
{
|
||||
<li>
|
||||
<a href="/registry/@registry.PublicLinkCode">@registry.Title</a>
|
||||
|
|
||||
<a href="/registry/@registry.Id/admin">Admin</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Visited registries</h2>
|
||||
@if (VisitedRegistries.Count == 0)
|
||||
{
|
||||
<p>No visited registries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul>
|
||||
@foreach (var registry in VisitedRegistries)
|
||||
{
|
||||
<li><a href="/registry/@registry.PublicLinkCode">@registry.Title</a></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
@@ -0,0 +1,62 @@
|
||||
using BirthList.Web.Features.Registries;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
public partial class Home : ComponentBase
|
||||
{
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
|
||||
[SupplyParameterFromForm(FormName = "create-registry-form")]
|
||||
protected RegistryCreateModel Model { get; set; } = new();
|
||||
|
||||
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
|
||||
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
|
||||
protected string? ErrorMessage { get; private set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task CreateRegistryAsync()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
ErrorMessage = "You must be logged in to create a registry.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Model.Title))
|
||||
{
|
||||
ErrorMessage = "Title is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.CreateRegistryAsync(userId, Model, CancellationToken.None).ConfigureAwait(false);
|
||||
Model = new RegistryCreateModel
|
||||
{
|
||||
RegistryType = Model.RegistryType,
|
||||
ThemeKey = Model.ThemeKey
|
||||
};
|
||||
await LoadAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
MyRegistries = [];
|
||||
VisitedRegistries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
MyRegistries = await RegistryService.GetAdminRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
|
||||
VisitedRegistries = await RegistryService.GetVisitedRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
@page "/registry/{RegistryId:guid}/admin"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Blazored.TextEditor
|
||||
|
||||
<PageTitle>Registry Admin</PageTitle>
|
||||
|
||||
@if (!IsAuthorized)
|
||||
{
|
||||
<p>Access denied.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Registry Admin</h1>
|
||||
|
||||
@if (!IsSmtpConfigured)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
SMTP is not configured. Email features (identity emails and admin invite emails) are disabled. Configure the Smtp section in appsettings or user secrets.
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="mb-4">
|
||||
<h2>Settings</h2>
|
||||
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Baby name</label>
|
||||
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Birth date</label>
|
||||
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Currency</label>
|
||||
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Theme</label>
|
||||
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
|
||||
<option value="default">Default</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="modern">Modern</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Shipping address</label>
|
||||
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="3" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Top content</label>
|
||||
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" />
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Bank account name</label>
|
||||
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">IBAN</label>
|
||||
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">BIC</label>
|
||||
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary mt-3" type="submit">Save settings</button>
|
||||
</EditForm>
|
||||
</section>
|
||||
|
||||
<section class="mb-4">
|
||||
<h2>Invite admin</h2>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-8">
|
||||
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(InviteLink))
|
||||
{
|
||||
<p class="mt-2">Invite link: <a href="@InviteLink">@InviteLink</a></p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Add or edit item</h2>
|
||||
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name</label>
|
||||
<InputText class="form-control" @bind-Value="ItemModel.Name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Product URL</label>
|
||||
<div class="input-group">
|
||||
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Picture URL</label>
|
||||
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Description</label>
|
||||
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Price</label>
|
||||
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Currency</label>
|
||||
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Desired qty</label>
|
||||
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center gap-2">
|
||||
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
|
||||
<label>Participation</label>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Participation target</label>
|
||||
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center gap-2">
|
||||
<InputCheckbox @bind-Value="ItemModel.CanBeSecondHand" />
|
||||
<label>Second hand allowed</label>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center gap-2">
|
||||
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
|
||||
<label>Given</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary mt-3" type="submit">Save item</button>
|
||||
</EditForm>
|
||||
|
||||
<table class="table table-striped mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Desired Qty</th>
|
||||
<th>Participation</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Name</td>
|
||||
<td>@item.DesiredQuantity</td>
|
||||
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using BirthList.Web.Authorization;
|
||||
using BirthList.Web.Features.Registries;
|
||||
using BirthList.Web.Services;
|
||||
using Blazored.TextEditor;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
[Authorize]
|
||||
public partial class RegistryAdmin : ComponentBase
|
||||
{
|
||||
[Parameter] public Guid RegistryId { get; set; }
|
||||
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryMetadataService RegistryMetadataService { get; set; } = null!;
|
||||
[Inject] private RegistryAuthorizationService RegistryAuthorizationService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
|
||||
[Inject] private SmtpEmailSender EmailSender { get; set; } = null!;
|
||||
[Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!;
|
||||
|
||||
protected RegistrySettingsEditModel SettingsModel { get; } = new();
|
||||
protected RegistryItemEditModel ItemModel { get; private set; } = new();
|
||||
protected IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
|
||||
protected bool IsAuthorized { get; private set; }
|
||||
protected bool IsSmtpConfigured { get; private set; }
|
||||
protected string? InviteEmail { get; set; }
|
||||
protected string? InviteLink { get; private set; }
|
||||
protected BlazoredTextEditor? TextEditor { get; set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
IsAuthorized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
IsAuthorized = await RegistryAuthorizationService.IsRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!IsAuthorized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsSmtpConfigured = SmtpConfigurationStatusService.IsConfigured();
|
||||
await LoadAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || TextEditor is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
|
||||
{
|
||||
await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SaveSettingsAsync()
|
||||
{
|
||||
if (TextEditor is not null)
|
||||
{
|
||||
SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await RegistryService.UpdateRegistrySettingsAsync(RegistryId, SettingsModel, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task SaveItemAsync()
|
||||
{
|
||||
await RegistryService.UpsertRegistryItemAsync(RegistryId, ItemModel, CancellationToken.None).ConfigureAwait(false);
|
||||
ItemModel = new RegistryItemEditModel
|
||||
{
|
||||
CurrencyCode = SettingsModel.CurrencyCode,
|
||||
DesiredQuantity = 1
|
||||
};
|
||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task DeleteItemAsync(Guid? itemId)
|
||||
{
|
||||
if (!itemId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.DeleteRegistryItemAsync(RegistryId, itemId.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected void EditItem(RegistryItemEditModel model)
|
||||
{
|
||||
ItemModel = new RegistryItemEditModel
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name,
|
||||
PictureUrl = model.PictureUrl,
|
||||
ProductUrl = model.ProductUrl,
|
||||
Description = model.Description,
|
||||
PriceAmount = model.PriceAmount,
|
||||
CurrencyCode = model.CurrencyCode,
|
||||
DesiredQuantity = model.DesiredQuantity,
|
||||
ParticipationAllowed = model.ParticipationAllowed,
|
||||
ParticipationTargetAmount = model.ParticipationTargetAmount,
|
||||
CanBeSecondHand = model.CanBeSecondHand,
|
||||
IsGiven = model.IsGiven
|
||||
};
|
||||
}
|
||||
|
||||
protected async Task FetchMetadataAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.ProductUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false);
|
||||
if (metadata is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.Name) && !string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
ItemModel.Name = metadata.Title;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.Description) && !string.IsNullOrWhiteSpace(metadata.Description))
|
||||
{
|
||||
ItemModel.Description = metadata.Description;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl))
|
||||
{
|
||||
ItemModel.PictureUrl = metadata.ImageUrl;
|
||||
}
|
||||
|
||||
if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue)
|
||||
{
|
||||
ItemModel.PriceAmount = metadata.PriceAmount;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.CurrencyCode) && !string.IsNullOrWhiteSpace(metadata.CurrencyCode))
|
||||
{
|
||||
ItemModel.CurrencyCode = metadata.CurrencyCode;
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task CreateInviteAsync()
|
||||
{
|
||||
var inviteId = await RegistryService.CreateAdminInviteAsync(RegistryId, InviteEmail, TimeSpan.FromDays(7), CancellationToken.None).ConfigureAwait(false);
|
||||
var token = await RegistryService.GetInviteTokenAsync(inviteId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InviteLink = NavigationManager.ToAbsoluteUri($"/registry/{RegistryId}/invite/{Uri.EscapeDataString(token)}").AbsoluteUri;
|
||||
if (!string.IsNullOrWhiteSpace(InviteEmail))
|
||||
{
|
||||
await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (settings is not null)
|
||||
{
|
||||
SettingsModel.BabyName = settings.BabyName;
|
||||
SettingsModel.BirthDate = settings.BirthDate;
|
||||
SettingsModel.HeaderContentHtml = settings.HeaderContentHtml;
|
||||
SettingsModel.ShippingAddress = settings.ShippingAddress;
|
||||
SettingsModel.CurrencyCode = settings.CurrencyCode;
|
||||
SettingsModel.ThemeKey = settings.ThemeKey;
|
||||
SettingsModel.BankAccountDisplayName = settings.BankAccountDisplayName;
|
||||
SettingsModel.BankAccountIban = settings.BankAccountIban;
|
||||
SettingsModel.BankAccountBic = settings.BankAccountBic;
|
||||
}
|
||||
|
||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
ItemModel = new RegistryItemEditModel
|
||||
{
|
||||
CurrencyCode = SettingsModel.CurrencyCode,
|
||||
DesiredQuantity = 1
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/registry/{RegistryId:guid}/invite/{Token}"
|
||||
|
||||
<PageTitle>Admin Invite</PageTitle>
|
||||
|
||||
<h1>Admin invitation</h1>
|
||||
|
||||
@if (Redeemed is null)
|
||||
{
|
||||
<p>Validating invitation...</p>
|
||||
}
|
||||
else if (Redeemed.Value)
|
||||
{
|
||||
<p>Invitation accepted. You are now an admin.</p>
|
||||
<a class="btn btn-primary" href="/registry/@RegistryId/admin">Go to admin</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>The invitation is invalid or already used.</p>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using BirthList.Web.Features.Registries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
[Authorize]
|
||||
public partial class RegistryInvite : ComponentBase
|
||||
{
|
||||
[Parameter] public Guid RegistryId { get; set; }
|
||||
[Parameter] public string Token { get; set; } = string.Empty;
|
||||
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
|
||||
protected bool? Redeemed { get; private set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(Token))
|
||||
{
|
||||
Redeemed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Redeemed = await RegistryService.RedeemAdminInviteAsync(Token, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
@page "/registry/{Code}"
|
||||
|
||||
@using BirthList.Web.Features.Registries
|
||||
|
||||
<PageTitle>Registry</PageTitle>
|
||||
|
||||
@if (Registry is null)
|
||||
{
|
||||
<p>Registry not found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)">
|
||||
<h1>@(string.IsNullOrWhiteSpace(Registry.BabyName) ? Registry.Title : Registry.BabyName)</h1>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.HeaderContentHtml))
|
||||
{
|
||||
<div class="header-content">@((MarkupString)Registry.HeaderContentHtml)</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.ShippingAddress))
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<strong>Shipping address</strong><br />
|
||||
@Registry.ShippingAddress
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountIban))
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
<strong>Bank transfer participation</strong><br />
|
||||
@Registry.BankAccountDisplayName<br />
|
||||
IBAN: @Registry.BankAccountIban
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
|
||||
{
|
||||
<span> | BIC: @Registry.BankAccountBic</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="item-grid">
|
||||
@foreach (var item in Registry.Items)
|
||||
{
|
||||
<article class="card item-card">
|
||||
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
|
||||
{
|
||||
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
|
||||
}
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">@item.Name</h5>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
<p class="card-text">@item.Description</p>
|
||||
}
|
||||
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
|
||||
@if (item.PriceAmount.HasValue)
|
||||
{
|
||||
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
||||
}
|
||||
@if (item.ParticipationAllowed && item.ParticipationTargetAmount.HasValue)
|
||||
{
|
||||
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
||||
}
|
||||
|
||||
@if (!IsAuthenticated)
|
||||
{
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ProductUrl))
|
||||
{
|
||||
<a class="btn btn-primary btn-sm" href="@item.ProductUrl" target="_blank" @onclick="() => BeginPurchase(item.Id)">Purchase</a>
|
||||
}
|
||||
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
|
||||
@if (item.ParticipationAllowed)
|
||||
{
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">I transferred money</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowPurchasePrompt)
|
||||
{
|
||||
<div class="prompt-overlay">
|
||||
<div class="prompt-card">
|
||||
<h3>Mark as purchased</h3>
|
||||
<p>How many units did you purchase?</p>
|
||||
<InputNumber class="form-control" @bind-Value="PurchasedQuantity" />
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowContributionPrompt)
|
||||
{
|
||||
<div class="prompt-overlay">
|
||||
<div class="prompt-card">
|
||||
<h3>Log contribution</h3>
|
||||
<p>Transferred amount</p>
|
||||
<InputNumber class="form-control" @bind-Value="ContributionAmount" />
|
||||
<p class="mt-2">Message: @ContributionMessage</p>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-success" @onclick="ConfirmContributionAsync">Confirm</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using BirthList.Web.Features.Registries;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
public partial class RegistryPublic : ComponentBase
|
||||
{
|
||||
[Parameter] public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
[Inject] private RegistryThemeService RegistryThemeService { get; set; } = null!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
|
||||
|
||||
protected RegistryPublicViewModel? Registry { get; private set; }
|
||||
protected bool IsAuthenticated { get; private set; }
|
||||
protected bool ShowPurchasePrompt { get; private set; }
|
||||
protected bool ShowContributionPrompt { get; private set; }
|
||||
protected Guid ActiveItemId { get; private set; }
|
||||
protected int PurchasedQuantity { get; set; } = 1;
|
||||
protected decimal ContributionAmount { get; set; }
|
||||
protected string ContributionMessage { get; set; } = string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
|
||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected void LoginRedirect()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString($"/registry/{Code}")}", forceLoad: true);
|
||||
}
|
||||
|
||||
protected void BeginPurchase(Guid itemId)
|
||||
{
|
||||
ActiveItemId = itemId;
|
||||
PurchasedQuantity = 1;
|
||||
}
|
||||
|
||||
protected void OpenPurchasePrompt(Guid itemId)
|
||||
{
|
||||
ActiveItemId = itemId;
|
||||
PurchasedQuantity = 1;
|
||||
ShowPurchasePrompt = true;
|
||||
}
|
||||
|
||||
protected void ClosePurchasePrompt()
|
||||
{
|
||||
ShowPurchasePrompt = false;
|
||||
ActiveItemId = Guid.Empty;
|
||||
}
|
||||
|
||||
protected async Task ConfirmPurchaseAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.AddPurchaseAsync(ActiveItemId, userId, PurchasedQuantity, CancellationToken.None).ConfigureAwait(false);
|
||||
ShowPurchasePrompt = false;
|
||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected void OpenContributionPrompt(Guid itemId)
|
||||
{
|
||||
ActiveItemId = itemId;
|
||||
ContributionAmount = 0;
|
||||
ContributionMessage = Registry?.Items.FirstOrDefault(x => x.Id == itemId) is { } item
|
||||
? $"Participation for item: {item.Name}"
|
||||
: string.Empty;
|
||||
ShowContributionPrompt = true;
|
||||
}
|
||||
|
||||
protected void CloseContributionPrompt()
|
||||
{
|
||||
ShowContributionPrompt = false;
|
||||
ActiveItemId = Guid.Empty;
|
||||
ContributionMessage = string.Empty;
|
||||
}
|
||||
|
||||
protected async Task ConfirmContributionAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
|
||||
ShowContributionPrompt = false;
|
||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.registry-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
max-height: 220px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.prompt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.prompt-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
width: min(420px, 95vw);
|
||||
}
|
||||
|
||||
.theme-birth-default h1 {
|
||||
color: #d36e70;
|
||||
}
|
||||
|
||||
.theme-wedding-default h1 {
|
||||
color: #8257e6;
|
||||
}
|
||||
|
||||
.theme-birthday-default h1 {
|
||||
color: #198754;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@using BirthList.Web.Components.Account.Shared
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,11 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using BirthList.Web
|
||||
@using BirthList.Web.Components
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace BirthList.Web.Configuration;
|
||||
|
||||
internal sealed class SmtpOptions
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int Port { get; set; } = 587;
|
||||
public bool EnableSsl { get; set; } = true;
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string FromAddress { get; set; } = string.Empty;
|
||||
public string FromName { get; set; } = "Birth Registry";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BirthList.Web.Data;
|
||||
|
||||
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace BirthList.Web.Data;
|
||||
|
||||
// Add profile data for application users by adding properties to the ApplicationUser class
|
||||
public class ApplicationUser : IdentityUser
|
||||
{
|
||||
}
|
||||
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BirthList.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BirthList.Web.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("00000000000000_CreateIdentitySchema")]
|
||||
partial class CreateIdentitySchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
|
||||
|
||||
modelBuilder.Entity("BirthList.Web.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex")
|
||||
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex")
|
||||
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BirthList.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CreateIdentitySchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUsers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
|
||||
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoleClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserLogins",
|
||||
columns: table => new
|
||||
{
|
||||
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserRoles",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetRoleClaims_RoleId",
|
||||
table: "AspNetRoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
table: "AspNetRoles",
|
||||
column: "NormalizedName",
|
||||
unique: true,
|
||||
filter: "[NormalizedName] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserClaims_UserId",
|
||||
table: "AspNetUserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserLogins_UserId",
|
||||
table: "AspNetUserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserRoles_RoleId",
|
||||
table: "AspNetUserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedUserName",
|
||||
unique: true,
|
||||
filter: "[NormalizedUserName] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoleClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserLogins");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BirthList.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BirthList.Web.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
|
||||
|
||||
modelBuilder.Entity("BirthList.Web.Data.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex")
|
||||
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex")
|
||||
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/BirthList.Web/BirthList.Web.csproj", "src/BirthList.Web/"]
|
||||
COPY ["src/BirthList.Infrastructure/BirthList.Infrastructure.csproj", "src/BirthList.Infrastructure/"]
|
||||
COPY ["src/BirthList.Domain/BirthList.Domain.csproj", "src/BirthList.Domain/"]
|
||||
RUN dotnet restore "src/BirthList.Web/BirthList.Web.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/src/BirthList.Web"
|
||||
RUN dotnet publish "BirthList.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "BirthList.Web.dll"]
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly Regex MetaTagRegex = new("<meta\\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
private static readonly Regex AttributeRegex = new("(name|property|content)\\s*=\\s*['\"]([^'\"]*)['\"]", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
|
||||
public async Task<UrlMetadataResult?> FetchAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("RegistryMetadata");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
request.Headers.UserAgent.ParseAdd("BirthListBot/1.0");
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var meta = ParseMeta(html);
|
||||
meta.TryGetValue("product:price:amount", out var priceRaw);
|
||||
meta.TryGetValue("product:price:currency", out var currency);
|
||||
|
||||
decimal? price = null;
|
||||
if (!string.IsNullOrWhiteSpace(priceRaw) && decimal.TryParse(priceRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsedPrice))
|
||||
{
|
||||
price = parsedPrice;
|
||||
}
|
||||
|
||||
return new UrlMetadataResult
|
||||
{
|
||||
Title = First(meta, "og:title", "twitter:title", "title"),
|
||||
Description = First(meta, "og:description", "twitter:description", "description"),
|
||||
ImageUrl = First(meta, "og:image", "twitter:image"),
|
||||
PriceAmount = price,
|
||||
CurrencyCode = string.IsNullOrWhiteSpace(currency) ? null : currency.ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseMeta(string html)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (Match metaTag in MetaTagRegex.Matches(html))
|
||||
{
|
||||
var tag = metaTag.Value;
|
||||
string? key = null;
|
||||
string? value = null;
|
||||
|
||||
foreach (Match attributeMatch in AttributeRegex.Matches(tag))
|
||||
{
|
||||
var attribute = attributeMatch.Groups[1].Value;
|
||||
var attributeValue = attributeMatch.Groups[2].Value;
|
||||
|
||||
if (string.Equals(attribute, "name", StringComparison.OrdinalIgnoreCase) || string.Equals(attribute, "property", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key = attributeValue;
|
||||
}
|
||||
else if (string.Equals(attribute, "content", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = attributeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
map[key.Trim()] = value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
var titleStart = html.IndexOf("<title>", StringComparison.OrdinalIgnoreCase);
|
||||
var titleEnd = html.IndexOf("</title>", StringComparison.OrdinalIgnoreCase);
|
||||
if (titleStart >= 0 && titleEnd > titleStart)
|
||||
{
|
||||
var title = html.Substring(titleStart + 7, titleEnd - (titleStart + 7)).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
map["title"] = title;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string? First(IDictionary<string, string> map, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (map.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using BirthList.Domain.Entities;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
public sealed class RegistryCreateModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public RegistryType RegistryType { get; set; } = RegistryType.Birth;
|
||||
public string ThemeKey { get; set; } = "default";
|
||||
}
|
||||
|
||||
public sealed class RegistryItemEditModel
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? PictureUrl { get; set; }
|
||||
public string? ProductUrl { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal? PriceAmount { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public int DesiredQuantity { get; set; } = 1;
|
||||
public bool ParticipationAllowed { get; set; }
|
||||
public decimal? ParticipationTargetAmount { get; set; }
|
||||
public bool CanBeSecondHand { get; set; }
|
||||
public bool IsGiven { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegistrySettingsEditModel
|
||||
{
|
||||
public string? BabyName { get; set; }
|
||||
public DateOnly? BirthDate { get; set; }
|
||||
public string? HeaderContentHtml { get; set; }
|
||||
public string? ShippingAddress { get; set; }
|
||||
public string CurrencyCode { get; set; } = "EUR";
|
||||
public string ThemeKey { get; set; } = "default";
|
||||
public string? BankAccountIban { get; set; }
|
||||
public string? BankAccountBic { get; set; }
|
||||
public string? BankAccountDisplayName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegistrySummaryViewModel
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string PublicLinkCode { get; init; } = string.Empty;
|
||||
public RegistryType RegistryType { get; init; }
|
||||
public string ThemeKey { get; init; } = "default";
|
||||
}
|
||||
|
||||
public sealed class RegistryPublicViewModel
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string PublicLinkCode { get; init; } = string.Empty;
|
||||
public string? BabyName { get; init; }
|
||||
public string? HeaderContentHtml { get; init; }
|
||||
public string? ShippingAddress { get; init; }
|
||||
public string CurrencyCode { get; init; } = "EUR";
|
||||
public string ThemeKey { get; init; } = "default";
|
||||
public RegistryType RegistryType { get; init; }
|
||||
public string? BankAccountIban { get; init; }
|
||||
public string? BankAccountBic { get; init; }
|
||||
public string? BankAccountDisplayName { get; init; }
|
||||
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RegistryPublicItemViewModel
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? PictureUrl { get; init; }
|
||||
public string? ProductUrl { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public decimal? PriceAmount { get; init; }
|
||||
public string CurrencyCode { get; init; } = "EUR";
|
||||
public int DesiredQuantity { get; init; }
|
||||
public int PurchasedQuantity { get; init; }
|
||||
public bool ParticipationAllowed { get; init; }
|
||||
public decimal? ParticipationTargetAmount { get; init; }
|
||||
public decimal MoneyFulfilledAmount { get; init; }
|
||||
public bool CanBeSecondHand { get; init; }
|
||||
public bool IsGiven { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UrlMetadataResult
|
||||
{
|
||||
public string? Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? ImageUrl { get; init; }
|
||||
public decimal? PriceAmount { get; init; }
|
||||
public string? CurrencyCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using BirthList.Domain.Entities;
|
||||
using BirthList.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryService(RegistryDbContext registryDbContext)
|
||||
{
|
||||
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
ArgumentNullException.ThrowIfNull(model);
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
{
|
||||
throw new ArgumentException("Title is required.", nameof(model));
|
||||
}
|
||||
|
||||
var publicCode = await CreateUniquePublicCodeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var registry = new Registry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = model.Title.Trim(),
|
||||
RegistryType = model.RegistryType,
|
||||
ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(),
|
||||
PublicLinkCode = publicCode,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
CurrencyCode = "EUR"
|
||||
};
|
||||
|
||||
registryDbContext.Registries.Add(registry);
|
||||
registryDbContext.RegistryAdmins.Add(new RegistryAdmin
|
||||
{
|
||||
RegistryId = registry.Id,
|
||||
UserId = userId,
|
||||
AddedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
registryDbContext.RegistrySettings.Add(new RegistrySettings
|
||||
{
|
||||
RegistryId = registry.Id
|
||||
});
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return registry.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RegistrySummaryViewModel>> GetVisitedRegistriesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
return await registryDbContext.RegistryVisits
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderByDescending(x => x.LastVisitedAtUtc)
|
||||
.Select(x => new RegistrySummaryViewModel
|
||||
{
|
||||
Id = x.RegistryId,
|
||||
Title = x.Registry.Title,
|
||||
PublicLinkCode = x.Registry.PublicLinkCode,
|
||||
RegistryType = x.Registry.RegistryType,
|
||||
ThemeKey = x.Registry.ThemeKey
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RegistrySummaryViewModel>> GetAdminRegistriesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
return await registryDbContext.RegistryAdmins
|
||||
.Where(x => x.UserId == userId)
|
||||
.OrderByDescending(x => x.AddedAtUtc)
|
||||
.Select(x => new RegistrySummaryViewModel
|
||||
{
|
||||
Id = x.RegistryId,
|
||||
Title = x.Registry.Title,
|
||||
PublicLinkCode = x.Registry.PublicLinkCode,
|
||||
RegistryType = x.Registry.RegistryType,
|
||||
ThemeKey = x.Registry.ThemeKey
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var registry = await registryDbContext.Registries
|
||||
.Include(x => x.Items)
|
||||
.Include(x => x.Visits)
|
||||
.Include(x => x.Admins)
|
||||
.FirstOrDefaultAsync(x => x.PublicLinkCode == code.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (registry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var settings = await registryDbContext.RegistrySettings
|
||||
.FirstOrDefaultAsync(x => x.RegistryId == registry.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
var existingVisit = await registryDbContext.RegistryVisits
|
||||
.FirstOrDefaultAsync(x => x.RegistryId == registry.Id && x.UserId == userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existingVisit is null)
|
||||
{
|
||||
registryDbContext.RegistryVisits.Add(new RegistryVisit
|
||||
{
|
||||
RegistryId = registry.Id,
|
||||
UserId = userId,
|
||||
LastVisitedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existingVisit.LastVisitedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new RegistryPublicViewModel
|
||||
{
|
||||
Id = registry.Id,
|
||||
Title = registry.Title,
|
||||
PublicLinkCode = registry.PublicLinkCode,
|
||||
BabyName = registry.BabyName,
|
||||
HeaderContentHtml = registry.HeaderContentHtml,
|
||||
ShippingAddress = registry.ShippingAddress,
|
||||
CurrencyCode = registry.CurrencyCode,
|
||||
RegistryType = registry.RegistryType,
|
||||
ThemeKey = registry.ThemeKey,
|
||||
BankAccountIban = settings?.BankAccountIban,
|
||||
BankAccountBic = settings?.BankAccountBic,
|
||||
BankAccountDisplayName = settings?.BankAccountDisplayName,
|
||||
Items = registry.Items
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new RegistryPublicItemViewModel
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
PictureUrl = x.PictureUrl,
|
||||
ProductUrl = x.ProductUrl,
|
||||
Description = x.Description,
|
||||
PriceAmount = x.PriceAmount,
|
||||
CurrencyCode = x.CurrencyCode,
|
||||
DesiredQuantity = x.DesiredQuantity,
|
||||
PurchasedQuantity = x.PurchasedQuantity,
|
||||
ParticipationAllowed = x.ParticipationAllowed,
|
||||
ParticipationTargetAmount = x.ParticipationTargetAmount,
|
||||
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
|
||||
CanBeSecondHand = x.CanBeSecondHand,
|
||||
IsGiven = x.IsGiven
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RegistrySettingsEditModel?> GetRegistrySettingsAsync(Guid registryId, CancellationToken cancellationToken)
|
||||
{
|
||||
var registry = await registryDbContext.Registries
|
||||
.FirstOrDefaultAsync(x => x.Id == registryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (registry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var settings = await registryDbContext.RegistrySettings
|
||||
.FirstOrDefaultAsync(x => x.RegistryId == registryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new RegistrySettingsEditModel
|
||||
{
|
||||
BabyName = registry.BabyName,
|
||||
BirthDate = registry.BirthDate,
|
||||
HeaderContentHtml = registry.HeaderContentHtml,
|
||||
ShippingAddress = registry.ShippingAddress,
|
||||
CurrencyCode = registry.CurrencyCode,
|
||||
ThemeKey = registry.ThemeKey,
|
||||
BankAccountIban = settings?.BankAccountIban,
|
||||
BankAccountBic = settings?.BankAccountBic,
|
||||
BankAccountDisplayName = settings?.BankAccountDisplayName
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateRegistrySettingsAsync(Guid registryId, RegistrySettingsEditModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(model);
|
||||
|
||||
var registry = await registryDbContext.Registries
|
||||
.FirstOrDefaultAsync(x => x.Id == registryId, cancellationToken)
|
||||
.ConfigureAwait(false) ?? throw new InvalidOperationException("Registry not found.");
|
||||
|
||||
var settings = await registryDbContext.RegistrySettings
|
||||
.FirstOrDefaultAsync(x => x.RegistryId == registryId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (settings is null)
|
||||
{
|
||||
settings = new RegistrySettings
|
||||
{
|
||||
RegistryId = registryId
|
||||
};
|
||||
registryDbContext.RegistrySettings.Add(settings);
|
||||
}
|
||||
|
||||
registry.BabyName = string.IsNullOrWhiteSpace(model.BabyName) ? null : model.BabyName.Trim();
|
||||
registry.BirthDate = model.BirthDate;
|
||||
registry.HeaderContentHtml = string.IsNullOrWhiteSpace(model.HeaderContentHtml) ? null : model.HeaderContentHtml;
|
||||
registry.ShippingAddress = string.IsNullOrWhiteSpace(model.ShippingAddress) ? null : model.ShippingAddress.Trim();
|
||||
registry.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant();
|
||||
registry.ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim();
|
||||
|
||||
settings.BankAccountIban = string.IsNullOrWhiteSpace(model.BankAccountIban) ? null : model.BankAccountIban.Trim();
|
||||
settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim();
|
||||
settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim();
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await registryDbContext.RegistryItems
|
||||
.Where(x => x.RegistryId == registryId)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new RegistryItemEditModel
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
PictureUrl = x.PictureUrl,
|
||||
ProductUrl = x.ProductUrl,
|
||||
Description = x.Description,
|
||||
PriceAmount = x.PriceAmount,
|
||||
CurrencyCode = x.CurrencyCode,
|
||||
DesiredQuantity = x.DesiredQuantity,
|
||||
ParticipationAllowed = x.ParticipationAllowed,
|
||||
ParticipationTargetAmount = x.ParticipationTargetAmount,
|
||||
CanBeSecondHand = x.CanBeSecondHand,
|
||||
IsGiven = x.IsGiven
|
||||
})
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<RegistryItemEditModel?> GetRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await registryDbContext.RegistryItems
|
||||
.Where(x => x.RegistryId == registryId && x.Id == itemId)
|
||||
.Select(x => new RegistryItemEditModel
|
||||
{
|
||||
Id = x.Id,
|
||||
Name = x.Name,
|
||||
PictureUrl = x.PictureUrl,
|
||||
ProductUrl = x.ProductUrl,
|
||||
Description = x.Description,
|
||||
PriceAmount = x.PriceAmount,
|
||||
CurrencyCode = x.CurrencyCode,
|
||||
DesiredQuantity = x.DesiredQuantity,
|
||||
ParticipationAllowed = x.ParticipationAllowed,
|
||||
ParticipationTargetAmount = x.ParticipationTargetAmount,
|
||||
CanBeSecondHand = x.CanBeSecondHand,
|
||||
IsGiven = x.IsGiven
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertRegistryItemAsync(Guid registryId, RegistryItemEditModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(model);
|
||||
if (string.IsNullOrWhiteSpace(model.Name))
|
||||
{
|
||||
throw new ArgumentException("Item name is required.", nameof(model));
|
||||
}
|
||||
|
||||
RegistryItem entity;
|
||||
if (model.Id is { } itemId)
|
||||
{
|
||||
entity = await registryDbContext.RegistryItems
|
||||
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
|
||||
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
entity = new RegistryItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RegistryId = registryId,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
registryDbContext.RegistryItems.Add(entity);
|
||||
}
|
||||
|
||||
entity.Name = model.Name.Trim();
|
||||
entity.PictureUrl = string.IsNullOrWhiteSpace(model.PictureUrl) ? null : model.PictureUrl.Trim();
|
||||
entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim();
|
||||
entity.Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim();
|
||||
entity.PriceAmount = model.PriceAmount;
|
||||
entity.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant();
|
||||
entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity;
|
||||
entity.ParticipationAllowed = model.ParticipationAllowed;
|
||||
entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null;
|
||||
entity.CanBeSecondHand = model.CanBeSecondHand;
|
||||
entity.IsGiven = model.IsGiven;
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteRegistryItemAsync(Guid registryId, Guid itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await registryDbContext.RegistryItems
|
||||
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
registryDbContext.RegistryItems.Remove(entity);
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AddPurchaseAsync(Guid itemId, string userId, int quantity, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
var item = await registryDbContext.RegistryItems
|
||||
.FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken)
|
||||
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
|
||||
|
||||
var normalizedQuantity = quantity < 1 ? 1 : quantity;
|
||||
|
||||
registryDbContext.ItemPurchases.Add(new ItemPurchase
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RegistryItemId = itemId,
|
||||
UserId = userId,
|
||||
Quantity = normalizedQuantity,
|
||||
PurchasedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
item.PurchasedQuantity += normalizedQuantity;
|
||||
item.IsGiven = item.PurchasedQuantity >= item.DesiredQuantity;
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AddContributionAsync(Guid itemId, string userId, decimal amount, string transferMessage, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
if (amount <= 0)
|
||||
{
|
||||
throw new ArgumentException("Amount must be positive.", nameof(amount));
|
||||
}
|
||||
|
||||
var item = await registryDbContext.RegistryItems
|
||||
.FirstOrDefaultAsync(x => x.Id == itemId, cancellationToken)
|
||||
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
|
||||
|
||||
registryDbContext.ItemContributions.Add(new ItemContribution
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RegistryItemId = itemId,
|
||||
UserId = userId,
|
||||
Amount = amount,
|
||||
CurrencyCode = item.CurrencyCode,
|
||||
TransferMessage = transferMessage,
|
||||
ContributedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
item.MoneyFulfilledAmount += amount;
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAdminInviteAsync(Guid registryId, string? email, TimeSpan validFor, CancellationToken cancellationToken)
|
||||
{
|
||||
var invite = new RegistryAdminInvite
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RegistryId = registryId,
|
||||
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(36)).Replace('+', '-').Replace('/', '_').TrimEnd('='),
|
||||
SentToEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim(),
|
||||
ExpiresAtUtc = DateTimeOffset.UtcNow.Add(validFor)
|
||||
};
|
||||
|
||||
registryDbContext.RegistryAdminInvites.Add(invite);
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return invite.Id;
|
||||
}
|
||||
|
||||
public async Task<string?> GetInviteTokenAsync(Guid inviteId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await registryDbContext.RegistryAdminInvites
|
||||
.Where(x => x.Id == inviteId)
|
||||
.Select(x => x.Token)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> RedeemAdminInviteAsync(string token, string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(token);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
var invite = await registryDbContext.RegistryAdminInvites
|
||||
.FirstOrDefaultAsync(x => x.Token == token, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (invite is null || invite.RedeemedAtUtc.HasValue || invite.ExpiresAtUtc < DateTimeOffset.UtcNow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isAdmin = await registryDbContext.RegistryAdmins
|
||||
.AnyAsync(x => x.RegistryId == invite.RegistryId && x.UserId == userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
registryDbContext.RegistryAdmins.Add(new RegistryAdmin
|
||||
{
|
||||
RegistryId = invite.RegistryId,
|
||||
UserId = userId,
|
||||
AddedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
invite.RedeemedAtUtc = DateTimeOffset.UtcNow;
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string> CreateUniquePublicCodeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
var codeBytes = RandomNumberGenerator.GetBytes(6);
|
||||
var code = Convert.ToHexString(codeBytes).ToLowerInvariant();
|
||||
var exists = await registryDbContext.Registries
|
||||
.AnyAsync(x => x.PublicLinkCode == code, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
var fallback = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")))).Substring(0, 16).ToLowerInvariant();
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using BirthList.Domain.Entities;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryThemeService
|
||||
{
|
||||
public string GetCssClass(RegistryType registryType, string? themeKey)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(themeKey) ? "default" : themeKey.Trim().ToLowerInvariant();
|
||||
var typeSegment = registryType.ToString().ToLowerInvariant();
|
||||
return $"theme-{typeSegment}-{normalized}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace BirthList.Web.Features.Registries;
|
||||
|
||||
internal sealed class RegistryUserContext(AuthenticationStateProvider authenticationStateProvider)
|
||||
{
|
||||
public async Task<string?> GetUserIdAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return state.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
|
||||
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return state.User.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using BirthList.Infrastructure.Persistence;
|
||||
using BirthList.Web.Authorization;
|
||||
using BirthList.Web.Configuration;
|
||||
using BirthList.Web.Features.Registries;
|
||||
using BirthList.Web.Services;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using BirthList.Web.Components;
|
||||
using BirthList.Web.Components.Account;
|
||||
using BirthList.Web.Data;
|
||||
using System.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddHttpClient("RegistryMetadata", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<IdentityUserAccessor>();
|
||||
builder.Services.AddScoped<IdentityRedirectManager>();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>();
|
||||
builder.Services.AddScoped<RegistryAuthorizationService>();
|
||||
builder.Services.AddScoped<OwnerBootstrapService>();
|
||||
builder.Services.AddScoped<RegistryService>();
|
||||
builder.Services.AddScoped<RegistryMetadataService>();
|
||||
builder.Services.AddScoped<RegistryThemeService>();
|
||||
builder.Services.AddScoped<RegistryUserContext>();
|
||||
builder.Services.AddScoped<SmtpConfigurationStatusService>();
|
||||
|
||||
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
|
||||
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
|
||||
var microsoftClientId = builder.Configuration["Authentication:Microsoft:ClientId"];
|
||||
var microsoftClientSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"];
|
||||
|
||||
var authBuilder = builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = IdentityConstants.ApplicationScheme;
|
||||
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
|
||||
});
|
||||
|
||||
authBuilder.AddIdentityCookies();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(googleClientSecret))
|
||||
{
|
||||
authBuilder.AddGoogle(options =>
|
||||
{
|
||||
options.ClientId = googleClientId;
|
||||
options.ClientSecret = googleClientSecret;
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(microsoftClientId) && !string.IsNullOrWhiteSpace(microsoftClientSecret))
|
||||
{
|
||||
authBuilder.AddMicrosoftAccount(options =>
|
||||
{
|
||||
options.ClientId = microsoftClientId;
|
||||
options.ClientSecret = microsoftClientSecret;
|
||||
});
|
||||
}
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||
var dataProvider = builder.Configuration["Data:Provider"] ?? "SqlServer";
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||
{
|
||||
if (string.Equals(dataProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
options.UseSqlServer(connectionString);
|
||||
return;
|
||||
}
|
||||
|
||||
options.UseSqlite(connectionString);
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<RegistryDbContext>(options =>
|
||||
{
|
||||
if (string.Equals(dataProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
options.UseSqlServer(connectionString);
|
||||
return;
|
||||
}
|
||||
|
||||
options.UseSqlite(connectionString);
|
||||
});
|
||||
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.SignIn.RequireConfirmedAccount = false;
|
||||
})
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddSignInManager()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.AddScoped<SmtpEmailSender>();
|
||||
builder.Services.AddScoped<IEmailSender<ApplicationUser>>(serviceProvider => serviceProvider.GetRequiredService<SmtpEmailSender>());
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var applicationDbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
await applicationDbContext.Database.MigrateAsync();
|
||||
|
||||
var registryDbContext = scope.ServiceProvider.GetRequiredService<RegistryDbContext>();
|
||||
var registryMigrations = registryDbContext.Database.GetMigrations();
|
||||
|
||||
if (registryMigrations.Any())
|
||||
{
|
||||
await registryDbContext.Database.MigrateAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var databaseCreator = registryDbContext.Database.GetService<IRelationalDatabaseCreator>();
|
||||
if (!await databaseCreator.ExistsAsync())
|
||||
{
|
||||
await registryDbContext.Database.EnsureCreatedAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var platformOwnersTableExists = await TableExistsAsync(registryDbContext, "PlatformOwners");
|
||||
if (!platformOwnersTableExists)
|
||||
{
|
||||
await databaseCreator.CreateTablesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseMigrationsEndPoint();
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
// Add additional endpoints required by the Identity /Account Razor components.
|
||||
app.MapAdditionalIdentityEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<bool> TableExistsAsync(DbContext context, string tableName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
|
||||
|
||||
var connection = context.Database.GetDbConnection();
|
||||
var closeConnection = connection.State != ConnectionState.Open;
|
||||
|
||||
if (closeConnection)
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
|
||||
if (context.Database.IsSqlServer())
|
||||
{
|
||||
command.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @tableName";
|
||||
}
|
||||
else if (context.Database.IsSqlite())
|
||||
{
|
||||
command.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @tableName";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException("Unsupported database provider for table existence check.");
|
||||
}
|
||||
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = "@tableName";
|
||||
parameter.Value = tableName;
|
||||
command.Parameters.Add(parameter);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
return result is not null && result != DBNull.Value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (closeConnection)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:62219",
|
||||
"sslPort": 44363
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5022",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7149;http://localhost:5022",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using BirthList.Domain.Entities;
|
||||
using BirthList.Infrastructure.Persistence;
|
||||
using BirthList.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BirthList.Web.Services;
|
||||
|
||||
internal sealed class OwnerBootstrapService(RegistryDbContext registryDbContext)
|
||||
{
|
||||
public async Task EnsureFirstUserIsOwnerAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.Id))
|
||||
{
|
||||
throw new InvalidOperationException("User id is required.");
|
||||
}
|
||||
|
||||
var hasOwner = await registryDbContext.PlatformOwners
|
||||
.AnyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (hasOwner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var alreadyOwner = await registryDbContext.PlatformOwners
|
||||
.AnyAsync(x => x.UserId == user.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (alreadyOwner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
registryDbContext.PlatformOwners.Add(new PlatformOwner
|
||||
{
|
||||
UserId = user.Id,
|
||||
AssignedAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using BirthList.Web.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BirthList.Web.Services;
|
||||
|
||||
internal sealed class SmtpConfigurationStatusService(IOptions<SmtpOptions> smtpOptions)
|
||||
{
|
||||
public bool IsConfigured()
|
||||
{
|
||||
var options = smtpOptions.Value;
|
||||
return !string.IsNullOrWhiteSpace(options.Host)
|
||||
&& options.Port > 0
|
||||
&& !string.IsNullOrWhiteSpace(options.UserName)
|
||||
&& !string.IsNullOrWhiteSpace(options.Password)
|
||||
&& !string.IsNullOrWhiteSpace(options.FromAddress);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user