diff --git a/JSMR.Api/JSMR.Api.csproj b/JSMR.Api/JSMR.Api.csproj index e72e0ef..fd7a51c 100644 --- a/JSMR.Api/JSMR.Api.csproj +++ b/JSMR.Api/JSMR.Api.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/JSMR.Api/JSMR.Api.http b/JSMR.Api/JSMR.Api.http index 081663e..128fa95 100644 --- a/JSMR.Api/JSMR.Api.http +++ b/JSMR.Api/JSMR.Api.http @@ -1,6 +1,31 @@ @host = http://localhost:5226 @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 POST {{host}}/api/tags/search Content-Type: {{contentType}} diff --git a/JSMR.Api/Program.cs b/JSMR.Api/Program.cs index 17f2973..a1a476c 100644 --- a/JSMR.Api/Program.cs +++ b/JSMR.Api/Program.cs @@ -1,158 +1,23 @@ -using JSMR.Application.Circles.Queries.Search; -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; +using JSMR.Api.Startup; -var builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -// Add services to the container. builder.Services - .AddMemoryCache() // TODO - .AddApplication() - .AddInfrastructure(); + .AddAppServices(builder) + .AddAppJson() + .AddAppOpenApi() + .AddAppAuthentication() + .AddAppCors(builder) + .AddAppLogging(builder); -// DbContext (MySQL here; swap to Npgsql when you migrate) -var cs = builder.Configuration.GetConnectionString("AppDb") - ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb2"); +builder.Host.UseAppSerilog(); -builder.Services.AddDbContextFactory(opt => - opt.UseMySql(cs, ServerVersion.AutoDetect(cs)) - .EnableSensitiveDataLogging(false)); +WebApplication app = builder.Build(); +app.UseAppPipeline(app.Environment); -builder.Services.AddControllers(); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -builder.Services.Configure(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()) -{ - app.MapOpenApi(); -} + await app.SeedDevelopmentAsync(); -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(); \ No newline at end of file diff --git a/JSMR.Api/Startup/DevSeeding.cs b/JSMR.Api/Startup/DevSeeding.cs new file mode 100644 index 0000000..17ae0bb --- /dev/null +++ b/JSMR.Api/Startup/DevSeeding.cs @@ -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(); + + 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"); + } + } +} \ No newline at end of file diff --git a/JSMR.Api/Startup/HostBuilderExtensions.cs b/JSMR.Api/Startup/HostBuilderExtensions.cs new file mode 100644 index 0000000..1466f7b --- /dev/null +++ b/JSMR.Api/Startup/HostBuilderExtensions.cs @@ -0,0 +1,9 @@ +using Serilog; + +namespace JSMR.Api.Startup; + +public static class HostBuilderExtensions +{ + public static IHostBuilder UseAppSerilog(this IHostBuilder host) + => host.UseSerilog(); +} \ No newline at end of file diff --git a/JSMR.Api/Startup/ServiceCollectionExtensions.cs b/JSMR.Api/Startup/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..dc52255 --- /dev/null +++ b/JSMR.Api/Startup/ServiceCollectionExtensions.cs @@ -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(opt => + opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)) + .EnableSensitiveDataLogging(false)); + + services.AddControllers(); + + return services; + } + + public static IServiceCollection AddAppJson(this IServiceCollection services) + { + services.Configure(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() ?? []; + + 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; + } +} \ No newline at end of file diff --git a/JSMR.Api/Startup/WebApplicationExtensions.cs b/JSMR.Api/Startup/WebApplicationExtensions.cs new file mode 100644 index 0000000..b2d74c0 --- /dev/null +++ b/JSMR.Api/Startup/WebApplicationExtensions.cs @@ -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 + { + 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); +} \ No newline at end of file diff --git a/JSMR.Api/appsettings.json b/JSMR.Api/appsettings.json index d7310f9..93ce949 100644 --- a/JSMR.Api/appsettings.json +++ b/JSMR.Api/appsettings.json @@ -6,6 +6,15 @@ } }, "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": { "MinimumLevel": { "Default": "Information", diff --git a/JSMR.Application/Users/IUserRepository.cs b/JSMR.Application/Users/IUserRepository.cs new file mode 100644 index 0000000..72e665b --- /dev/null +++ b/JSMR.Application/Users/IUserRepository.cs @@ -0,0 +1,9 @@ +using JSMR.Domain.Entities; + +namespace JSMR.Application.Users; + +public interface IUserRepository +{ + Task FindByUsernameAsync(string username); + bool VerifyPassword(User user, string password); +} \ No newline at end of file diff --git a/JSMR.Domain/Entities/User.cs b/JSMR.Domain/Entities/User.cs new file mode 100644 index 0000000..94812bb --- /dev/null +++ b/JSMR.Domain/Entities/User.cs @@ -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; +} \ No newline at end of file diff --git a/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs b/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs index 2f11c0c..26c8535 100644 --- a/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs +++ b/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using JSMR.Application.Integrations.Ports; using JSMR.Application.Scanning.Ports; using JSMR.Application.Tags.Ports; using JSMR.Application.Tags.Queries.Search.Ports; +using JSMR.Application.Users; using JSMR.Application.VoiceWorks.Ports; using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Infrastructure.Caching; @@ -19,6 +20,7 @@ using JSMR.Infrastructure.Common.Time; using JSMR.Infrastructure.Data.Repositories.Circles; using JSMR.Infrastructure.Data.Repositories.Creators; using JSMR.Infrastructure.Data.Repositories.Tags; +using JSMR.Infrastructure.Data.Repositories.Users; using JSMR.Infrastructure.Data.Repositories.VoiceWorks; using JSMR.Infrastructure.Http; using JSMR.Infrastructure.Ingestion; @@ -108,6 +110,8 @@ public static class InfrastructureServiceCollectionExtensions // Register DLSiteClient as a normal scoped service services.AddScoped(); + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/AppDbContext.cs b/JSMR.Infrastructure/Data/AppDbContext.cs index 426e682..af261ea 100644 --- a/JSMR.Infrastructure/Data/AppDbContext.cs +++ b/JSMR.Infrastructure/Data/AppDbContext.cs @@ -17,6 +17,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet VoiceWorkCreators { get; set; } public DbSet Series { get; set; } public DbSet VoiceWorkSearches { get; set; } + public DbSet Users { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/JSMR.Infrastructure/Data/Repositories/Users/UserRepository.cs b/JSMR.Infrastructure/Data/Repositories/Users/UserRepository.cs new file mode 100644 index 0000000..e9fb1c6 --- /dev/null +++ b/JSMR.Infrastructure/Data/Repositories/Users/UserRepository.cs @@ -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 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); + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/JSMR.Infrastructure.csproj b/JSMR.Infrastructure/JSMR.Infrastructure.csproj index d61d50c..e13a1e9 100644 --- a/JSMR.Infrastructure/JSMR.Infrastructure.csproj +++ b/JSMR.Infrastructure/JSMR.Infrastructure.csproj @@ -15,6 +15,7 @@ + diff --git a/JSMR.UI.Blazor/App.razor b/JSMR.UI.Blazor/App.razor index 85c87b3..5018329 100644 --- a/JSMR.UI.Blazor/App.razor +++ b/JSMR.UI.Blazor/App.razor @@ -1,4 +1,8 @@ - +@using JSMR.UI.Blazor.Services + +@inject SessionState Session + + @@ -13,4 +17,11 @@

Sorry, there's nothing at this address.

- \ No newline at end of file + + +@code { + protected override async Task OnInitializedAsync() + { + await Session.RefreshAsync(); + } +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Components/Authentication/RequireAuthentication.razor b/JSMR.UI.Blazor/Components/Authentication/RequireAuthentication.razor new file mode 100644 index 0000000..6cf491b --- /dev/null +++ b/JSMR.UI.Blazor/Components/Authentication/RequireAuthentication.razor @@ -0,0 +1,35 @@ +@using JSMR.UI.Blazor.Services + +@inject SessionState Session +@inject NavigationManager Nav + +@if (!ready) +{ +

Loading...

+} +else if (!Session.IsAuthenticated) +{ + +} +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); + } + } +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj b/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj index b51e092..013b715 100644 --- a/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj +++ b/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj @@ -14,6 +14,7 @@ +
diff --git a/JSMR.UI.Blazor/Layout/MainLayout.razor b/JSMR.UI.Blazor/Layout/MainLayout.razor index 6995617..0eeb0fa 100644 --- a/JSMR.UI.Blazor/Layout/MainLayout.razor +++ b/JSMR.UI.Blazor/Layout/MainLayout.razor @@ -1,4 +1,20 @@ -@inherits LayoutComponentBase +@using JSMR.UI.Blazor.Services + +@inject SessionState Session + +@inherits LayoutComponentBase + +
+ @if (Session.IsAuthenticated) + { + Logged in as @Session.Me?.name (@Session.Me?.role) + Logout + } + else + { + Login + } +
@@ -56,4 +72,21 @@ 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; + } } \ No newline at end of file diff --git a/JSMR.UI.Blazor/Pages/Login.razor b/JSMR.UI.Blazor/Pages/Login.razor new file mode 100644 index 0000000..6a25bbc --- /dev/null +++ b/JSMR.UI.Blazor/Pages/Login.razor @@ -0,0 +1,86 @@ +@page "/login" + +@using JSMR.UI.Blazor.Services + +@inject SessionState Session +@inject NavigationManager Nav + +

Login

+ +@if (Session.IsAuthenticated) +{ +

You're already logged in as @Session.Me?.name.

+ +} +else +{ +
+
+
+ +
+
+
+ +
+ +
+ +
+ + @if (!string.IsNullOrWhiteSpace(error)) + { +

@error

+ } +
+} + +@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; + } + } +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Pages/VoiceWorks.razor b/JSMR.UI.Blazor/Pages/VoiceWorks.razor index 9f467a2..af5ca2d 100644 --- a/JSMR.UI.Blazor/Pages/VoiceWorks.razor +++ b/JSMR.UI.Blazor/Pages/VoiceWorks.razor @@ -2,6 +2,7 @@ @using JSMR.Application.Common.Search @using JSMR.Application.VoiceWorks.Queries.Search @using JSMR.UI.Blazor.Components +@using JSMR.UI.Blazor.Components.Authentication @using JSMR.UI.Blazor.Enums @using JSMR.UI.Blazor.Filters @using JSMR.UI.Blazor.Services @@ -11,6 +12,7 @@ @inherits SearchPageBase + Voice Works

Voice Works

@@ -40,6 +42,7 @@ } +
@code { [Inject] diff --git a/JSMR.UI.Blazor/Program.cs b/JSMR.UI.Blazor/Program.cs index 0b88936..3653a5c 100644 --- a/JSMR.UI.Blazor/Program.cs +++ b/JSMR.UI.Blazor/Program.cs @@ -14,7 +14,31 @@ string apiBase = builder.Configuration["ApiBaseUrl"] ?? builder.HostEnvironment. 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(); + +//builder.Services.AddSingleton(); +//builder.Services.AddTransient(); + +// Register a named client that uses the handler +builder.Services.AddHttpClient("Api", client => +{ + client.BaseAddress = new Uri(apiBase); +}) +.AddHttpMessageHandler(); +//.AddHttpMessageHandler(); + +// Keep your existing pattern (inject HttpClient) by mapping it to the named client +builder.Services.AddScoped(sp => + sp.GetRequiredService().CreateClient("Api") +); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddMudServices(); builder.Services.AddRadzenComponents(); builder.Services.AddBitBlazorUIServices(); diff --git a/JSMR.UI.Blazor/Services/AuthenticationClient.cs b/JSMR.UI.Blazor/Services/AuthenticationClient.cs new file mode 100644 index 0000000..05df3e9 --- /dev/null +++ b/JSMR.UI.Blazor/Services/AuthenticationClient.cs @@ -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 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 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(cancellationToken: ct); + } + + public sealed record MeResponse(string? name, string? id, string? role); +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Services/IncludeRequestCredentialsMessageHandler.cs b/JSMR.UI.Blazor/Services/IncludeRequestCredentialsMessageHandler.cs new file mode 100644 index 0000000..40e2a2d --- /dev/null +++ b/JSMR.UI.Blazor/Services/IncludeRequestCredentialsMessageHandler.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Http; + +namespace JSMR.UI.Blazor.Services; + +public sealed class IncludeRequestCredentialsMessageHandler : DelegatingHandler +{ + protected override Task 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); + } +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Services/JwtAuthorizationMessageHandler.cs b/JSMR.UI.Blazor/Services/JwtAuthorizationMessageHandler.cs new file mode 100644 index 0000000..95db40d --- /dev/null +++ b/JSMR.UI.Blazor/Services/JwtAuthorizationMessageHandler.cs @@ -0,0 +1,21 @@ +using System.Net.Http.Headers; + +namespace JSMR.UI.Blazor.Services; + +public sealed class JwtAuthorizationMessageHandler(TokenStore tokens) : DelegatingHandler +{ + protected override Task 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; } +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Services/SessionState.cs b/JSMR.UI.Blazor/Services/SessionState.cs new file mode 100644 index 0000000..71c2217 --- /dev/null +++ b/JSMR.UI.Blazor/Services/SessionState.cs @@ -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 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); + } +} \ No newline at end of file