From f70fe4f674f93de931cca50716ed0e9c27bb922a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:52:00 -0500 Subject: [PATCH 1/3] Fix Azure Static Web Apps authentication by adding navigationFallback configuration (#34) * Initial plan * Add navigationFallback to fix auth routing issue Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> * Add critical /.auth/* exclusion to navigationFallback Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> * Update documentation to explain authentication configuration Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> * Update Bezalu.ProjectReporting.Web/staticwebapp.config.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add service-worker.published.js to static web app config --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../staticwebapp.config.json | 16 ++++++++++++++++ README.md | 14 ++++++++++---- docs/deployment.md | 5 ++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Bezalu.ProjectReporting.Web/staticwebapp.config.json b/Bezalu.ProjectReporting.Web/staticwebapp.config.json index 3eeff23..6654357 100644 --- a/Bezalu.ProjectReporting.Web/staticwebapp.config.json +++ b/Bezalu.ProjectReporting.Web/staticwebapp.config.json @@ -9,6 +9,22 @@ "rewrite": "/.auth/login/aad" } ], + "navigationFallback": { + "rewrite": "index.html", + "exclude": [ + "/.auth/*", + "/_framework/*", + "/_content/*", + "/css/*", + "/js/*", + "/favicon.ico", + "/icon-*.png", + "/manifest.webmanifest", + "/service-worker.js", + "/service-worker.published.js", + "/service-worker-assets.js" + ] + }, "responseOverrides": { "401": { "redirect": "/.auth/login/aad?post_login_redirect_uri=.referrer", diff --git a/README.md b/README.md index 3f05339..d6c960d 100644 --- a/README.md +++ b/README.md @@ -134,10 +134,16 @@ CW-ProjectReporting/ 1. User accesses the Blazor WebAssembly app hosted on Azure Static Web Apps 2. Static Web Apps enforces authentication via Azure AD (configured in `staticwebapp.config.json`) -3. Authenticated users receive session cookies from SWA -4. API requests include SWA authentication cookies automatically -5. SWA validates authentication before forwarding requests to Azure Functions -6. Azure Functions trust the SWA authentication layer (no additional function-level auth required) +3. Unauthenticated users are automatically redirected to `/.auth/login/aad` (Azure AD login) +4. Authenticated users receive session cookies from SWA +5. API requests include SWA authentication cookies automatically +6. SWA validates authentication before forwarding requests to Azure Functions +7. Azure Functions trust the SWA authentication layer (no additional function-level auth required) + +**Important**: The `staticwebapp.config.json` must include: +- `navigationFallback` with `/.auth/*` exclusion to ensure authentication endpoints work correctly +- `responseOverrides` for 401 status to automatically redirect unauthenticated users +- Exclusions for Blazor static assets (`_framework/*`, `_content/*`) from the SPA fallback ## Development Notes diff --git a/docs/deployment.md b/docs/deployment.md index 0e185d6..cc085d5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -31,7 +31,10 @@ - Configure authentication provider in Azure Portal under SWA > Settings > Authentication - `staticwebapp.config.json` enforces authentication: - `/api/*` routes require `authenticated` role - - Unauthenticated users redirected to `/.auth/login/aad` + - `/login` route rewrites to `/.auth/login/aad` for easy access + - Unauthenticated users automatically redirected to Azure AD via `responseOverrides` (401 → `/.auth/login/aad`) + - `navigationFallback` configured with `/.auth/*` exclusion to ensure authentication endpoints work correctly + - Blazor static assets (`_framework/*`, `_content/*`, CSS, JS) excluded from SPA fallback - No function keys needed - Azure Functions use `AuthorizationLevel.Anonymous` and trust SWA authentication - Session managed by SWA; cookies automatically included in API requests From f092c5de7954e3d610d8930c8824ab03d3ac2416 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:58:56 -0500 Subject: [PATCH 2/3] Add project picker UI with active projects endpoint (#32) * Initial plan * Add project picker feature with active project listing Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> * Address code review feedback: improve error handling and null checks Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> * Improve error messages to avoid exposing sensitive information Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MWG-Logan <2997336+MWG-Logan@users.noreply.github.com> --- .../Functions/ProjectsFunction.cs | 35 ++++++ .../Services/ProjectReportingService.cs | 28 ++++- .../DTOs/ProjectCompletionReportModels.cs | 9 ++ Bezalu.ProjectReporting.Web/Pages/Home.razor | 116 ++++++++++++++---- 4 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs diff --git a/Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs b/Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs new file mode 100644 index 0000000..7308930 --- /dev/null +++ b/Bezalu.ProjectReporting.API/Functions/ProjectsFunction.cs @@ -0,0 +1,35 @@ +using Bezalu.ProjectReporting.API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace Bezalu.ProjectReporting.API.Functions; + +public class ProjectsFunction( + ILogger logger, + IProjectReportingService reportingService) +{ + [Function("GetActiveProjects")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "projects")] + HttpRequest req, + CancellationToken cancellationToken) + { + logger.LogInformation("Processing get active projects request"); + + try + { + var projects = await reportingService.GetActiveProjectsAsync(cancellationToken); + return new OkObjectResult(projects); + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving active projects"); + return new ObjectResult(new { error = "An error occurred while retrieving projects" }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } +} diff --git a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs index 5dd7d9a..978f03f 100644 --- a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs +++ b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs @@ -7,6 +7,7 @@ namespace Bezalu.ProjectReporting.API.Services; public interface IProjectReportingService { + Task> GetActiveProjectsAsync(CancellationToken cancellationToken = default); Task GenerateProjectCompletionReportAsync(int projectId, CancellationToken cancellationToken = default); } @@ -16,6 +17,27 @@ public class ProjectReportingService( ILogger logger) : IProjectReportingService { + public async Task> GetActiveProjectsAsync(CancellationToken cancellationToken = default) + { + logger.LogInformation("Retrieving active projects"); + + // Query ConnectWise for projects with "Active" status + var projects = await connectWiseClient.GetListAsync( + "project/projects?conditions=status/name='Active'&orderBy=name", + cancellationToken) ?? new List(); + + return projects + .Where(p => p.Id.HasValue && p.Id.Value > 0) + .Select(p => new ProjectListItem + { + ProjectId = p.Id!.Value, + ProjectName = p.Name, + Status = p.Status?.Name, + Manager = p.Manager?.Name, + Company = p.Company?.Name + }).ToList(); + } + public async Task GenerateProjectCompletionReportAsync( int projectId, CancellationToken cancellationToken = default) @@ -161,7 +183,7 @@ private static string Sanitize(string? text, int maxLen = 500) { if (string.IsNullOrWhiteSpace(text)) return string.Empty; var cleaned = text.Replace("\r", " ").Replace("\n", " ").Trim(); - return cleaned.Length <= maxLen ? cleaned : cleaned.Substring(0, maxLen) + ""; + return cleaned.Length <= maxLen ? cleaned : cleaned.Substring(0, maxLen) + "�"; } private string PrepareDataForAI(ProjectCompletionReportResponse report, List projectNotes, Dictionary> ticketNotes) @@ -216,11 +238,11 @@ private string PrepareDataForAI(ProjectCompletionReportResponse report, List limited.Count) { - sb.AppendLine($" ({notes.Count - limited.Count} more notes truncated)"); + sb.AppendLine($" � � ({notes.Count - limited.Count} more notes truncated)"); } } } diff --git a/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs b/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs index bb64194..2169846 100644 --- a/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs +++ b/Bezalu.ProjectReporting.Shared/DTOs/ProjectCompletionReportModels.cs @@ -1,5 +1,14 @@ namespace Bezalu.ProjectReporting.Shared.DTOs; +public class ProjectListItem +{ + public int ProjectId { get; set; } + public string? ProjectName { get; set; } + public string? Status { get; set; } + public string? Manager { get; set; } + public string? Company { get; set; } +} + public class ProjectCompletionReportRequest { public int ProjectId { get; set; } diff --git a/Bezalu.ProjectReporting.Web/Pages/Home.razor b/Bezalu.ProjectReporting.Web/Pages/Home.razor index d0a374d..8235ff9 100644 --- a/Bezalu.ProjectReporting.Web/Pages/Home.razor +++ b/Bezalu.ProjectReporting.Web/Pages/Home.razor @@ -6,25 +6,54 @@ Project Reporting - -

Generate Project Completion Report

- - - Generate Report - @(IsPdfLoading ? "Generating PDF..." : "Download PDF") - @if (IsLoading) +@if (Report is null) +{ + +

Select a Project

+ @if (IsLoadingProjects) { +

Loading projects...

} - @if (!string.IsNullOrEmpty(ErrorMessage)) + else if (!string.IsNullOrEmpty(ErrorMessage)) { @ErrorMessage } -
-
- -@if (Report is not null) + else if (Projects is null || Projects.Count == 0) + { +

No active projects found.

+ } + else + { + + + + + + + + @(IsGeneratingReport && GeneratingProjectId == context.ProjectId ? "Generating..." : "Generate Report") + + + + } + +} +else { + + + ← Back to Project List + @(IsPdfLoading ? "Generating PDF..." : "Download PDF") + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + @ErrorMessage + } + + +

@Report.ProjectName (#@Report.ProjectId)

@@ -81,42 +110,79 @@ } @code { - int ProjectId { get; set; } + List? Projects; ProjectCompletionReportResponse? Report; - bool IsLoading; + bool IsLoadingProjects; + bool IsGeneratingReport; + int GeneratingProjectId; bool IsPdfLoading; string? ErrorMessage; string AiSummaryHtml => Report?.AiGeneratedSummary is null ? string.Empty : Markdown.ToHtml(Report.AiGeneratedSummary); - async Task GenerateReport() + protected override async Task OnInitializedAsync() + { + await LoadProjects(); + } + + async Task LoadProjects() { ErrorMessage = null; - IsLoading = true; - Report = null; + IsLoadingProjects = true; + try + { + var response = await Http.GetAsync("api/projects"); + if (!response.IsSuccessStatusCode) + { + ErrorMessage = $"Failed to load projects: {response.StatusCode}"; + return; + } + Projects = await response.Content.ReadFromJsonAsync>(); + } + catch (Exception) + { + ErrorMessage = "An error occurred while loading projects. Please try again."; + } + finally + { + IsLoadingProjects = false; + } + } + + async Task GenerateReport(int projectId) + { + ErrorMessage = null; + IsGeneratingReport = true; + GeneratingProjectId = projectId; StateHasChanged(); try { - var req = new ProjectCompletionReportRequest { ProjectId = ProjectId }; + var req = new ProjectCompletionReportRequest { ProjectId = projectId }; var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion", req); if (!httpResp.IsSuccessStatusCode) { - ErrorMessage = $"Failed: {httpResp.StatusCode}"; + ErrorMessage = $"Failed to generate report: {httpResp.StatusCode}"; } else { Report = await httpResp.Content.ReadFromJsonAsync(); } } - catch (Exception ex) + catch (Exception) { - ErrorMessage = ex.Message; + ErrorMessage = "An error occurred while generating the report. Please try again."; } finally { - IsLoading = false; + IsGeneratingReport = false; } } + void BackToProjectList() + { + Report = null; + ErrorMessage = null; + } + async Task DownloadPdf() { if (Report is null) return; @@ -127,7 +193,7 @@ var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion/pdf", Report); if (!httpResp.IsSuccessStatusCode) { - ErrorMessage = $"PDF failed: {httpResp.StatusCode}"; + ErrorMessage = $"Failed to generate PDF: {httpResp.StatusCode}"; return; } @@ -135,9 +201,9 @@ var base64 = Convert.ToBase64String(bytes); await JS.InvokeVoidAsync("saveFile", base64, $"project-{Report.ProjectId}-completion-report.pdf", "application/pdf"); } - catch (Exception ex) + catch (Exception) { - ErrorMessage = ex.Message; + ErrorMessage = "An error occurred while generating the PDF. Please try again."; } finally { From 52dd1b06b05129c54efe7e15ee879dd25661d21a Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:28:12 -0500 Subject: [PATCH 3/3] Refactor report function, improve auth handling in UI (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed and relocated Azure Function for project completion reports (ProjectCompletionReportFunction → ReportFunction) for better organization. - Broadened "active" project filter to include "In Progress" status in ProjectReportingService. - Enhanced Blazor UI to detect 401 Unauthorized responses and show a login prompt with sign-in button. - Updated staticwebapp.config.json to require authentication for all routes (/*) instead of just /api/*. - Minor UI formatting improvements to data grids. --- ...ionReportFunction.cs => ReportFunction.cs} | 4 +- .../Services/ProjectReportingService.cs | 2 +- Bezalu.ProjectReporting.Web/Pages/Home.razor | 68 +++++++++++++++---- .../staticwebapp.config.json | 8 +-- 4 files changed, 63 insertions(+), 19 deletions(-) rename Bezalu.ProjectReporting.API/Functions/{ProjectCompletionReportFunction.cs => ReportFunction.cs} (99%) diff --git a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs b/Bezalu.ProjectReporting.API/Functions/ReportFunction.cs similarity index 99% rename from Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs rename to Bezalu.ProjectReporting.API/Functions/ReportFunction.cs index e7e8bf0..68112c4 100644 --- a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs +++ b/Bezalu.ProjectReporting.API/Functions/ReportFunction.cs @@ -10,8 +10,8 @@ namespace Bezalu.ProjectReporting.API.Functions; -public class ProjectCompletionReportFunction( - ILogger logger, +public class ReportFunction( + ILogger logger, IProjectReportingService reportingService) { [Function("GenerateProjectCompletionReport")] diff --git a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs index 978f03f..7c424f1 100644 --- a/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs +++ b/Bezalu.ProjectReporting.API/Services/ProjectReportingService.cs @@ -23,7 +23,7 @@ public async Task> GetActiveProjectsAsync(CancellationToke // Query ConnectWise for projects with "Active" status var projects = await connectWiseClient.GetListAsync( - "project/projects?conditions=status/name='Active'&orderBy=name", + "project/projects?conditions=status/name contains 'In Progress'&orderBy=name", cancellationToken) ?? new List(); return projects diff --git a/Bezalu.ProjectReporting.Web/Pages/Home.razor b/Bezalu.ProjectReporting.Web/Pages/Home.razor index 8235ff9..11519e4 100644 --- a/Bezalu.ProjectReporting.Web/Pages/Home.razor +++ b/Bezalu.ProjectReporting.Web/Pages/Home.razor @@ -1,4 +1,5 @@ @page "/" +@using System.Net @inject HttpClient Http @inject NavigationManager Nav @inject IJSRuntime JS @@ -6,13 +7,27 @@ Project Reporting +@if (ShowLoginPrompt) +{ + + + + + @LoginPromptText + Sign in again to continue. + + Sign in + + +} + @if (Report is null) {

Select a Project

@if (IsLoadingProjects) { - +

Loading projects...

} else if (!string.IsNullOrEmpty(ErrorMessage)) @@ -31,8 +46,8 @@ - @(IsGeneratingReport && GeneratingProjectId == context.ProjectId ? "Generating..." : "Generate Report") @@ -82,10 +97,10 @@ else else { - - - - + + + + } @@ -97,11 +112,11 @@ else else { - - - - - + + + + + } @@ -116,6 +131,8 @@ else bool IsGeneratingReport; int GeneratingProjectId; bool IsPdfLoading; + bool ShowLoginPrompt; + string LoginPromptText = "Please sign in to continue."; string? ErrorMessage; string AiSummaryHtml => Report?.AiGeneratedSummary is null ? string.Empty : Markdown.ToHtml(Report.AiGeneratedSummary); @@ -131,6 +148,11 @@ else try { var response = await Http.GetAsync("api/projects"); + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + HandleUnauthorized("Please sign in to load projects."); + return; + } if (!response.IsSuccessStatusCode) { ErrorMessage = $"Failed to load projects: {response.StatusCode}"; @@ -158,6 +180,11 @@ else { var req = new ProjectCompletionReportRequest { ProjectId = projectId }; var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion", req); + if (httpResp.StatusCode == HttpStatusCode.Unauthorized) + { + HandleUnauthorized("Please sign in to generate a report."); + return; + } if (!httpResp.IsSuccessStatusCode) { ErrorMessage = $"Failed to generate report: {httpResp.StatusCode}"; @@ -191,6 +218,11 @@ else try { var httpResp = await Http.PostAsJsonAsync("api/reports/project-completion/pdf", Report); + if (httpResp.StatusCode == HttpStatusCode.Unauthorized) + { + HandleUnauthorized("Please sign in to download the PDF."); + return; + } if (!httpResp.IsSuccessStatusCode) { ErrorMessage = $"Failed to generate PDF: {httpResp.StatusCode}"; @@ -210,4 +242,16 @@ else IsPdfLoading = false; } } + + void HandleUnauthorized(string message) + { + LoginPromptText = message; + ShowLoginPrompt = true; + ErrorMessage = null; + } + + void TriggerLogin() + { + Nav.NavigateTo("/login?post_login_redirect_uri=.referrer", forceLoad: true); + } } \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/staticwebapp.config.json b/Bezalu.ProjectReporting.Web/staticwebapp.config.json index 6654357..0143e37 100644 --- a/Bezalu.ProjectReporting.Web/staticwebapp.config.json +++ b/Bezalu.ProjectReporting.Web/staticwebapp.config.json @@ -1,12 +1,12 @@ { "routes": [ - { - "route": "/api/*", - "allowedRoles": ["authenticated"] - }, { "route": "/login", "rewrite": "/.auth/login/aad" + }, + { + "route": "/*", + "allowedRoles": ["authenticated"] } ], "navigationFallback": {