Added authenication/authorization. Refactored api startup.
Some checks failed
ci / build-test (push) Has been cancelled
ci / publish-image (push) Has been cancelled

This commit is contained in:
2026-02-16 00:20:02 -05:00
parent a85989a337
commit 9f30ef446a
25 changed files with 685 additions and 154 deletions

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>

View File

@@ -1,6 +1,31 @@
@host = http://localhost:5226 @host = http://localhost:5226
@contentType = application/json @contentType = application/json
### Login
POST {{host}}/auth/login
Content-Type: {{contentType}}
{
"Username": "brister",
"Password": "password"
}
### Logout
POST {{host}}/auth/logout
Content-Type: {{contentType}}
{
}
### Get current user
GET {{host}}/api/me
Content-Type: {{contentType}}
{
}
### Search tags by name ### Search tags by name
POST {{host}}/api/tags/search POST {{host}}/api/tags/search
Content-Type: {{contentType}} Content-Type: {{contentType}}

View File

@@ -1,158 +1,23 @@
using JSMR.Application.Circles.Queries.Search; using JSMR.Api.Startup;
using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.DI;
using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.DI;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Serilog.Events;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services builder.Services
.AddMemoryCache() // TODO .AddAppServices(builder)
.AddApplication() .AddAppJson()
.AddInfrastructure(); .AddAppOpenApi()
.AddAppAuthentication()
.AddAppCors(builder)
.AddAppLogging(builder);
// DbContext (MySQL here; swap to Npgsql when you migrate) builder.Host.UseAppSerilog();
var cs = builder.Configuration.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb2");
builder.Services.AddDbContextFactory<AppDbContext>(opt => WebApplication app = builder.Build();
opt.UseMySql(cs, ServerVersion.AutoDetect(cs)) app.UseAppPipeline(app.Environment);
.EnableSensitiveDataLogging(false));
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); // or null for exact names
});
// Serilog bootstrap (before Build)
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.WithProperty("Service", "JSMR.Api")
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.CreateLogger();
builder.Host.UseSerilog();
builder.Services.AddCors(o =>
{
o.AddPolicy("ui-dev", p => p
.AllowAnyOrigin()
//.WithOrigins(
// "*",
// "https://localhost:5173", // vite-like
// "https://localhost:5001", // typical https dev
// "http://localhost:5000", // typical http dev
// "https://localhost:7112", // blazor wasm dev https (adjust to your port)
// "http://localhost:5153" // blazor wasm dev http (adjust to your port)
//)
.AllowAnyHeader()
.AllowAnyMethod());
});
var app = builder.Build();
app.UseCors("ui-dev");
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ await app.SeedDevelopmentAsync();
app.MapOpenApi();
}
app.UseHttpsRedirection(); app.MapAppEndpoints();
app.UseAuthorization();
app.MapControllers();
// Request logging with latency, status, path
app.UseSerilogRequestLogging(opts =>
{
opts.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
});
// Correlation: ensure every log has traceId and return it to clients
app.Use(async (ctx, next) =>
{
// Use current Activity if present (W3C trace context), else fall back
var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier;
using (Serilog.Context.LogContext.PushProperty("TraceId", traceId))
{
ctx.Response.Headers["x-trace-id"] = traceId;
await next();
}
});
// Health check
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
// ---- Endpoints ----
// Circles Search
app.MapPost("/api/circles/search", async (
SearchCirclesRequest request,
SearchCirclesHandler handler,
CancellationToken cancallationToken) =>
{
try
{
SearchCirclesResponse result = await handler.HandleAsync(request, cancallationToken);
return Results.Ok(result);
}
catch (OperationCanceledException) when (cancallationToken.IsCancellationRequested)
{
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
});
// Voice Works Search
app.MapPost("/api/voiceworks/search", async (
SearchVoiceWorksRequest request,
SearchVoiceWorksHandler handler,
CancellationToken cancallationToken) =>
{
var result = await handler.HandleAsync(request, cancallationToken);
return Results.Ok(result);
});
// Tags Search
app.MapPost("/api/tags/search", async (
SearchTagsRequest request,
SearchTagsHandler handler,
CancellationToken cancallationToken) =>
{
var result = await handler.HandleAsync(request, cancallationToken);
return Results.Ok(result);
});
// Creators Search
app.MapPost("/api/creators/search", async (
SearchCreatorsRequest request,
SearchCreatorsHandler handler,
CancellationToken cancallationToken) =>
{
var result = await handler.HandleAsync(request, cancallationToken);
return Results.Ok(result);
});
app.Run(); app.Run();

View File

@@ -0,0 +1,30 @@
using JSMR.Domain.Entities;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Api.Startup;
public static class DevSeeding
{
public static async Task SeedDevelopmentAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var username = "brister";
if (!await db.Users.AnyAsync(u => u.Username == username))
{
db.Users.Add(new User
{
Username = username,
PasswordHash = BCrypt.Net.BCrypt.HashPassword("password"),
Role = "Admin",
IsActive = true
});
await db.SaveChangesAsync();
Console.WriteLine("✅ Seeded development user: brister / password");
}
}
}

View File

@@ -0,0 +1,9 @@
using Serilog;
namespace JSMR.Api.Startup;
public static class HostBuilderExtensions
{
public static IHostBuilder UseAppSerilog(this IHostBuilder host)
=> host.UseSerilog();
}

View File

@@ -0,0 +1,118 @@
using JSMR.Application.DI;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.DI;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.EntityFrameworkCore;
using Serilog;
using Serilog.Events;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JSMR.Api.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services, IHostApplicationBuilder builder)
{
services
.AddMemoryCache()
.AddApplication()
.AddInfrastructure();
string connectionString = builder.Configuration.GetConnectionString("AppDb")
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
services.AddDbContextFactory<AppDbContext>(opt =>
opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
.EnableSensitiveDataLogging(false));
services.AddControllers();
return services;
}
public static IServiceCollection AddAppJson(this IServiceCollection services)
{
services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.PropertyNameCaseInsensitive = true;
options.SerializerOptions.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
return services;
}
public static IServiceCollection AddAppOpenApi(this IServiceCollection services)
{
services.AddOpenApi();
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "vw_auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
},
OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
};
});
return services;
}
public static IServiceCollection AddAppCors(this IServiceCollection services, IHostApplicationBuilder builder)
{
// Prefer config-based origins so you stop editing code for ports.
// appsettings.Development.json:
// "Cors": { "AllowedOrigins": [ "https://localhost:7112", ... ] }
string[] origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
services.AddCors(options =>
{
options.AddPolicy("ui", policyBuilder =>
policyBuilder.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials());
});
return services;
}
public static IServiceCollection AddAppLogging(this IServiceCollection services, IHostApplicationBuilder builder)
{
var config = builder.Configuration;
var env = builder.Environment;
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(config)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.WithProperty("Service", "JSMR.Api")
.Enrich.WithProperty("Environment", env.EnvironmentName)
.CreateLogger();
return services;
}
}

View File

@@ -0,0 +1,145 @@
using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.Users;
using JSMR.Application.VoiceWorks.Queries.Search;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Serilog;
using System.Diagnostics;
using System.Security.Claims;
namespace JSMR.Api.Startup;
public static class WebApplicationExtensions
{
public static WebApplication UseAppPipeline(this WebApplication app, IHostEnvironment env)
{
app.UseCors("ui");
if (env.IsDevelopment())
app.MapOpenApi();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.UseSerilogRequestLogging(opts =>
{
opts.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
});
app.Use(async (ctx, next) =>
{
var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier;
using (Serilog.Context.LogContext.PushProperty("TraceId", traceId))
{
ctx.Response.Headers["x-trace-id"] = traceId;
await next();
}
});
return app;
}
public static void MapAppEndpoints(this WebApplication app)
{
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapSearchEndpoints();
app.MapAuthenticationEndpoints();
}
private static void MapSearchEndpoints(this WebApplication app)
{
app.MapPost("/api/circles/search", async (
SearchCirclesRequest request,
SearchCirclesHandler handler,
CancellationToken ct) =>
{
try
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
});
app.MapPost("/api/voiceworks/search", async (
SearchVoiceWorksRequest request,
SearchVoiceWorksHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/tags/search", async (
SearchTagsRequest request,
SearchTagsHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/creators/search", async (
SearchCreatorsRequest request,
SearchCreatorsHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
}
private static void MapAuthenticationEndpoints(this WebApplication app)
{
app.MapPost("/auth/login", async (LoginRequest req, IUserRepository users, HttpContext http) =>
{
var user = await users.FindByUsernameAsync(req.Username);
if (user is null || !user.IsActive)
return Results.Unauthorized();
if (!users.VerifyPassword(user, req.Password))
return Results.Unauthorized();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Role, user.Role ?? "User")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
return Results.Ok(new { ok = true });
});
app.MapPost("/auth/logout", async (HttpContext http) =>
{
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Ok(new { ok = true });
});
app.MapGet("/api/me", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
name = user.Identity?.Name,
id = user.FindFirstValue(ClaimTypes.NameIdentifier),
role = user.FindFirstValue(ClaimTypes.Role)
});
}).RequireAuthorization();
}
public record LoginRequest(string Username, string Password);
}

View File

@@ -6,6 +6,15 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Cors": {
"AllowedOrigins": [
"https://localhost:5173", // vite-like
"https://localhost:5001", // typical https dev
"http://localhost:5000", // typical http dev
"https://localhost:7112", // blazor wasm dev https (adjust to your port)
"http://localhost:5153" // blazor wasm dev http (adjust to your port)
]
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",

View File

@@ -0,0 +1,9 @@
using JSMR.Domain.Entities;
namespace JSMR.Application.Users;
public interface IUserRepository
{
Task<User?> FindByUsernameAsync(string username);
bool VerifyPassword(User user, string password);
}

View File

@@ -0,0 +1,10 @@
namespace JSMR.Domain.Entities;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string? Role { get; set; }
public bool IsActive { get; set; } = true;
}

View File

@@ -9,6 +9,7 @@ using JSMR.Application.Integrations.Ports;
using JSMR.Application.Scanning.Ports; using JSMR.Application.Scanning.Ports;
using JSMR.Application.Tags.Ports; using JSMR.Application.Tags.Ports;
using JSMR.Application.Tags.Queries.Search.Ports; using JSMR.Application.Tags.Queries.Search.Ports;
using JSMR.Application.Users;
using JSMR.Application.VoiceWorks.Ports; using JSMR.Application.VoiceWorks.Ports;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.Infrastructure.Caching; using JSMR.Infrastructure.Caching;
@@ -19,6 +20,7 @@ using JSMR.Infrastructure.Common.Time;
using JSMR.Infrastructure.Data.Repositories.Circles; using JSMR.Infrastructure.Data.Repositories.Circles;
using JSMR.Infrastructure.Data.Repositories.Creators; using JSMR.Infrastructure.Data.Repositories.Creators;
using JSMR.Infrastructure.Data.Repositories.Tags; using JSMR.Infrastructure.Data.Repositories.Tags;
using JSMR.Infrastructure.Data.Repositories.Users;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks; using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Http;
using JSMR.Infrastructure.Ingestion; using JSMR.Infrastructure.Ingestion;
@@ -108,6 +110,8 @@ public static class InfrastructureServiceCollectionExtensions
// Register DLSiteClient as a normal scoped service // Register DLSiteClient as a normal scoped service
services.AddScoped<IDLSiteClient, DLSiteClient>(); services.AddScoped<IDLSiteClient, DLSiteClient>();
services.AddScoped<IUserRepository, UserRepository>();
return services; return services;
} }
} }

View File

@@ -17,6 +17,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<VoiceWorkCreator> VoiceWorkCreators { get; set; } public DbSet<VoiceWorkCreator> VoiceWorkCreators { get; set; }
public DbSet<Series> Series { get; set; } public DbSet<Series> Series { get; set; }
public DbSet<VoiceWorkSearch> VoiceWorkSearches { get; set; } public DbSet<VoiceWorkSearch> VoiceWorkSearches { get; set; }
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@@ -0,0 +1,20 @@
using JSMR.Application.Users;
using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data.Repositories.Users;
public class UserRepository(AppDbContext db) : IUserRepository
{
public async Task<User?> FindByUsernameAsync(string username)
{
return await db.Users
.FirstOrDefaultAsync(u => u.Username == username);
}
public bool VerifyPassword(User user, string password)
{
// Using BCrypt (recommended)
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
}
}

View File

@@ -15,6 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />

View File

@@ -1,4 +1,8 @@
<HeadContent> @using JSMR.UI.Blazor.Services
@inject SessionState Session
<HeadContent>
<RadzenTheme Theme="material-dark" /> <RadzenTheme Theme="material-dark" />
</HeadContent> </HeadContent>
@@ -14,3 +18,10 @@
</LayoutView> </LayoutView>
</NotFound> </NotFound>
</Router> </Router>
@code {
protected override async Task OnInitializedAsync()
{
await Session.RefreshAsync();
}
}

View File

@@ -0,0 +1,35 @@
@using JSMR.UI.Blazor.Services
@inject SessionState Session
@inject NavigationManager Nav
@if (!ready)
{
<p>Loading...</p>
}
else if (!Session.IsAuthenticated)
{
<!-- nothing shown, we redirect -->
}
else
{
@ChildContent
}
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
private bool ready;
protected override async Task OnInitializedAsync()
{
await Session.RefreshAsync();
ready = true;
if (!Session.IsAuthenticated)
{
var returnUrl = Uri.EscapeDataString(Nav.Uri);
Nav.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: false);
}
}
}

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.10" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.13" />
<PackageReference Include="MudBlazor" Version="8.15.0" /> <PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Radzen.Blazor" Version="8.3.5" /> <PackageReference Include="Radzen.Blazor" Version="8.3.5" />
</ItemGroup> </ItemGroup>

View File

@@ -1,4 +1,20 @@
@inherits LayoutComponentBase @using JSMR.UI.Blazor.Services
@inject SessionState Session
@inherits LayoutComponentBase
<div class="topbar">
@if (Session.IsAuthenticated)
{
<span>Logged in as <b>@Session.Me?.name</b> (@Session.Me?.role)</span>
<a href="" @onclick="OnLogout" style="margin-left: 12px;">Logout</a>
}
else
{
<a href="/login">Login</a>
}
</div>
<MudLayout> <MudLayout>
<MudAppBar Elevation="1" Dense="@_dense"> <MudAppBar Elevation="1" Dense="@_dense">
@@ -56,4 +72,21 @@
StateHasChanged(); StateHasChanged();
} }
} }
protected override void OnInitialized()
{
Session.Changed += OnSessionChanged;
}
private void OnSessionChanged() => InvokeAsync(StateHasChanged);
private async Task OnLogout(MouseEventArgs _)
{
await Session.LogoutAsync();
}
public void Dispose()
{
Session.Changed -= OnSessionChanged;
}
} }

View File

@@ -0,0 +1,86 @@
@page "/login"
@using JSMR.UI.Blazor.Services
@inject SessionState Session
@inject NavigationManager Nav
<h3>Login</h3>
@if (Session.IsAuthenticated)
{
<p>You're already logged in as <b>@Session.Me?.name</b>.</p>
<button @onclick="Logout">Logout</button>
}
else
{
<div style="max-width: 360px;">
<div>
<label>Username</label><br />
<input @bind="username" />
</div>
<div style="margin-top: 8px;">
<label>Password</label><br />
<input type="password" @bind="password" />
</div>
<div style="margin-top: 12px;">
<button @onclick="LoginAsync" disabled="@busy">Login</button>
</div>
@if (!string.IsNullOrWhiteSpace(error))
{
<p style="color: crimson; margin-top: 8px;">@error</p>
}
</div>
}
@code {
private string username = "";
private string password = "";
private bool busy;
private string? error;
private async Task LoginAsync()
{
busy = true;
error = null;
try
{
var ok = await Session.LoginAsync(username, password);
if (!ok)
{
error = "Invalid username or password.";
return;
}
Nav.NavigateTo("/");
}
catch (Exception ex)
{
error = ex.Message;
}
finally
{
busy = false;
}
}
private async Task Logout()
{
busy = true;
error = null;
try
{
await Session.LogoutAsync();
}
catch (Exception ex)
{
error = ex.Message;
}
finally
{
busy = false;
}
}
}

View File

@@ -2,6 +2,7 @@
@using JSMR.Application.Common.Search @using JSMR.Application.Common.Search
@using JSMR.Application.VoiceWorks.Queries.Search @using JSMR.Application.VoiceWorks.Queries.Search
@using JSMR.UI.Blazor.Components @using JSMR.UI.Blazor.Components
@using JSMR.UI.Blazor.Components.Authentication
@using JSMR.UI.Blazor.Enums @using JSMR.UI.Blazor.Enums
@using JSMR.UI.Blazor.Filters @using JSMR.UI.Blazor.Filters
@using JSMR.UI.Blazor.Services @using JSMR.UI.Blazor.Services
@@ -11,6 +12,7 @@
@inherits SearchPageBase<VoiceWorkFilterState, VoiceWorkSearchResult> @inherits SearchPageBase<VoiceWorkFilterState, VoiceWorkSearchResult>
<RequireAuthentication>
<PageTitle>Voice Works</PageTitle> <PageTitle>Voice Works</PageTitle>
<h3>Voice Works</h3> <h3>Voice Works</h3>
@@ -40,6 +42,7 @@
</RightContent> </RightContent>
</JPagination> </JPagination>
} }
</RequireAuthentication>
@code { @code {
[Inject] [Inject]

View File

@@ -14,7 +14,31 @@ string apiBase = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment.
Console.WriteLine(apiBase); Console.WriteLine(apiBase);
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBase) }); // Old way
//builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBase) });
// Register the handler
builder.Services.AddTransient<IncludeRequestCredentialsMessageHandler>();
//builder.Services.AddSingleton<TokenStore>();
//builder.Services.AddTransient<JwtAuthorizationMessageHandler>();
// Register a named client that uses the handler
builder.Services.AddHttpClient("Api", client =>
{
client.BaseAddress = new Uri(apiBase);
})
.AddHttpMessageHandler<IncludeRequestCredentialsMessageHandler>();
//.AddHttpMessageHandler<JwtAuthorizationMessageHandler>();
// Keep your existing pattern (inject HttpClient) by mapping it to the named client
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("Api")
);
builder.Services.AddScoped<AuthenticationClient>();
builder.Services.AddScoped<SessionState>();
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddRadzenComponents(); builder.Services.AddRadzenComponents();
builder.Services.AddBitBlazorUIServices(); builder.Services.AddBitBlazorUIServices();

View File

@@ -0,0 +1,29 @@
using System.Net;
using System.Net.Http.Json;
namespace JSMR.UI.Blazor.Services;
public class AuthenticationClient(HttpClient http)
{
public async Task<bool> LoginAsync(string username, string password, CancellationToken ct = default)
{
var resp = await http.PostAsJsonAsync("/auth/login", new { username, password }, ct);
return resp.IsSuccessStatusCode;
}
public async Task LogoutAsync(CancellationToken ct = default)
=> await http.PostAsync("/auth/logout", content: null, ct);
public async Task<MeResponse?> GetMeAsync(CancellationToken ct = default)
{
var resp = await http.GetAsync("/api/me", ct);
if (resp.StatusCode == HttpStatusCode.Unauthorized)
return null;
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<MeResponse>(cancellationToken: ct);
}
public sealed record MeResponse(string? name, string? id, string? role);
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace JSMR.UI.Blazor.Services;
public sealed class IncludeRequestCredentialsMessageHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Tells browser fetch() to send cookies/auth headers on cross-origin requests
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,21 @@
using System.Net.Http.Headers;
namespace JSMR.UI.Blazor.Services;
public sealed class JwtAuthorizationMessageHandler(TokenStore tokens) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(tokens.AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}
return base.SendAsync(request, ct);
}
}
public sealed class TokenStore
{
public string? AccessToken { get; set; }
}

View File

@@ -0,0 +1,28 @@
namespace JSMR.UI.Blazor.Services;
public sealed class SessionState(AuthenticationClient auth)
{
public AuthenticationClient.MeResponse? Me { get; private set; }
public bool IsAuthenticated => Me is not null;
public event Action? Changed;
public async Task RefreshAsync(CancellationToken ct = default)
{
Me = await auth.GetMeAsync(ct);
Changed?.Invoke();
}
public async Task<bool> LoginAsync(string username, string password, CancellationToken ct = default)
{
var ok = await auth.LoginAsync(username, password, ct);
await RefreshAsync(ct);
return ok;
}
public async Task LogoutAsync(CancellationToken ct = default)
{
await auth.LogoutAsync(ct);
await RefreshAsync(ct);
}
}