using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; using TaskTrackingSystem.Shared; using TaskTrackingSystem.Shared.Models.Auth; using TaskTrackingSystem.WebApp.Components.Partial; namespace TaskTrackingSystem.WebApp; public static class AccountEndpoints { public static void MapAccountEndpoints(this WebApplication app) { app.MapPost("/account/login", LoginAsync); app.MapPost("/account/register", RegisterAsync); app.MapPost("/account/reset-password", ResetPasswordAsync); app.MapGet("/account/logout", LogoutAsync); app.MapPost("/account/logout", LogoutAsync); } private static async Task LoginAsync( HttpContext context, IHttpClientFactory httpClientFactory, MenuAuthorizationService menuAuthorization, [FromForm] string usernameOrEmail, [FromForm] string password, [FromForm] bool? rememberMe, [FromForm] string? returnUrl) { if (string.IsNullOrWhiteSpace(usernameOrEmail) || string.IsNullOrWhiteSpace(password)) { return RedirectToLogin(returnUrl, "invalid"); } HttpResponseMessage response; try { var client = httpClientFactory.CreateClient("WebApi"); response = await client.PostAsJsonAsync("Auth/login", new LoginDto { UsernameOrEmail = usernameOrEmail, Password = password }); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) { return RedirectToLogin(returnUrl, "unavailable"); } if (!response.IsSuccessStatusCode) { return RedirectToLogin(returnUrl, "invalid"); } var result = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); if (result?.IsSuccess != true || result.Value == null || string.IsNullOrWhiteSpace(result.Value.Username)) { return RedirectToLogin(returnUrl, "invalid"); } await SignInUserAsync(context, result.Value, rememberMe ?? false); var landingPage = await ResolveLandingPageAsync(menuAuthorization, result.Value); return Results.Redirect(GetSafeReturnUrl(returnUrl, landingPage)); } private static async Task RegisterAsync( HttpContext context, IHttpClientFactory httpClientFactory, MenuAuthorizationService menuAuthorization, [FromForm] RegisterDto registerDto) { HttpResponseMessage response; try { var client = httpClientFactory.CreateClient("WebApi"); response = await client.PostAsJsonAsync("Auth/register", registerDto); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) { return Results.Redirect($"/register?error={Uri.EscapeDataString("Unable to reach the API server. Please ensure TaskTrackingSystem.WebApi is running.")}"); } string errorMessage = ResultMessages.RegistrationFailed; if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync>(Serialization.CaseInsensitive); if (result?.IsSuccess == true && result.Value != null && !string.IsNullOrWhiteSpace(result.Value.Username)) { await SignInUserAsync(context, result.Value, false); var landingPage = await ResolveLandingPageAsync(menuAuthorization, result.Value); return Results.Redirect(GetSafeReturnUrl(null, landingPage)); } else if (result != null && !string.IsNullOrEmpty(result.ErrorMessage)) { errorMessage = result.ErrorMessage; } } else { try { var contentString = await response.Content.ReadAsStringAsync(); // Try parsing as Result try { var result = JsonSerializer.Deserialize>(contentString, Serialization.CaseInsensitive); if (result != null && !string.IsNullOrEmpty(result.ErrorMessage)) { errorMessage = result.ErrorMessage; } } catch { // Ignore and fall back to checking validation errors } // If no errorMessage from Result, try parsing standard validation errors if (errorMessage == ResultMessages.RegistrationFailed) { try { using var doc = JsonDocument.Parse(contentString); var root = doc.RootElement; if (root.TryGetProperty("errors", out var errorsProp) && errorsProp.ValueKind == JsonValueKind.Object) { var errorsList = new List(); foreach (var prop in errorsProp.EnumerateObject()) { foreach (var val in prop.Value.EnumerateArray()) { errorsList.Add(val.GetString() ?? ""); } } if (errorsList.Count > 0) { errorMessage = string.Join(" ", errorsList.Where(s => !string.IsNullOrEmpty(s))); } } else if (root.TryGetProperty("errorMessage", out var errMsgProp)) { errorMessage = errMsgProp.GetString() ?? errorMessage; } } catch { // Ignore and keep fallback } } } catch { // Fallback to default } } return Results.Redirect($"/register?error={Uri.EscapeDataString(errorMessage)}"); } private static async Task ResetPasswordAsync( HttpContext context, IHttpClientFactory httpClientFactory, [FromForm] ResetPasswordDto resetPasswordDto) { HttpResponseMessage response; try { var client = httpClientFactory.CreateClient("WebApi"); response = await client.PostAsJsonAsync("Auth/reset-password", resetPasswordDto); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) { return Results.Redirect($"/reset-password?error={Uri.EscapeDataString("Unable to reach the API server. Please ensure TaskTrackingSystem.WebApi is running.")}"); } if (response.IsSuccessStatusCode) { var result = await response.Content.ReadFromJsonAsync(Serialization.CaseInsensitive); if (result?.IsSuccess == true) { return Results.Redirect("/login?reset=true"); } } var errorMessage = "Failed to reset password."; if (response.Content != null) { try { var contentString = await response.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize(contentString, Serialization.CaseInsensitive); if (result != null && !string.IsNullOrWhiteSpace(result.ErrorMessage)) { errorMessage = result.ErrorMessage; } } catch { // Keep default error message. } } return Results.Redirect($"/reset-password?error={Uri.EscapeDataString(errorMessage)}"); } private static async Task LogoutAsync(HttpContext context, UserSessionState sessionState) { await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); context.Response.Cookies.Delete(CookieAuthenticationDefaults.AuthenticationScheme); sessionState.ClearSession(); return Results.Redirect("/login?loggedOut=true"); } private static async Task SignInUserAsync(HttpContext context, AuthResponseDto authResult, bool rememberMe) { var claims = new List { new(ClaimTypes.Name, authResult.Username), new(ClaimTypes.Email, authResult.Email) }; if (!string.IsNullOrWhiteSpace(authResult.RoleName)) { claims.Add(new Claim(ClaimTypes.Role, authResult.RoleName)); } if (authResult.RoleId > 0) { claims.Add(new Claim("role_id", authResult.RoleId.ToString())); } var userId = TryGetUserIdFromJwt(authResult.Token); if (!string.IsNullOrWhiteSpace(userId)) { claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); } claims.Add(new Claim("jwt_token", authResult.Token)); var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); await context.SignInAsync( CookieAuthenticationDefaults.AuthenticationScheme, principal, new AuthenticationProperties { IsPersistent = rememberMe, ExpiresUtc = rememberMe ? DateTimeOffset.UtcNow.AddDays(7) : DateTimeOffset.UtcNow.AddHours(8) }); } private static string? TryGetUserIdFromJwt(string token) { if (string.IsNullOrWhiteSpace(token)) { return null; } try { var parts = token.Split('.'); if (parts.Length < 2) { return null; } var payload = parts[1]; switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; } var json = Convert.FromBase64String(payload); var data = JsonSerializer.Deserialize>(json); if (data == null) { return null; } if (data.TryGetValue("nameid", out var nameId)) { return nameId.GetString(); } if (data.TryGetValue(ClaimTypes.NameIdentifier, out var fullNameId)) { return fullNameId.GetString(); } } catch { return null; } return null; } private static IResult RedirectToLogin(string? returnUrl, string error) { var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) ? string.Empty : $"&ReturnUrl={Uri.EscapeDataString(returnUrl)}"; return Results.Redirect($"/login?error={error}{safeReturnUrl}"); } private static string GetSafeReturnUrl(string? returnUrl, string landingPage) { if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith('/') || returnUrl.StartsWith("//")) { return landingPage; } return returnUrl; } private static async Task ResolveLandingPageAsync(MenuAuthorizationService menuAuthorization, AuthResponseDto authResult) { var principal = BuildTemporaryPrincipal(authResult); var menus = await menuAuthorization.GetUserMenusAsync(principal); var dashboardMenu = FindFirstDashboardMenu(menus); return dashboardMenu ?? "/dashboard"; } private static ClaimsPrincipal BuildTemporaryPrincipal(AuthResponseDto authResult) { var claims = new List { new(ClaimTypes.Name, authResult.Username), new(ClaimTypes.Email, authResult.Email), new(ClaimTypes.Role, authResult.RoleName), new("role_id", authResult.RoleId.ToString()), new("jwt_token", authResult.Token) }; return new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); } private static string? FindFirstDashboardMenu(IEnumerable menus) { foreach (var menu in menus) { var found = FindFirstDashboardMenu(menu); if (!string.IsNullOrWhiteSpace(found)) { return found; } } return null; } private static string? FindFirstDashboardMenu(TaskTrackingSystem.Shared.Models.Menu.MenuDto menu) { if (!string.IsNullOrWhiteSpace(menu.MenuUrl) && menu.MenuUrl.StartsWith("/dashboard", StringComparison.OrdinalIgnoreCase)) { return menu.MenuUrl; } foreach (var subMenu in menu.SubMenus) { var found = FindFirstDashboardMenu(subMenu); if (!string.IsNullOrWhiteSpace(found)) { return found; } } return null; } }