Skip to content

feat(portal): implement RBAC with permission gates and user management#526

Merged
aurelianware merged 3 commits intomainfrom
claude/implement-rbac-system-xfb2G
Mar 20, 2026
Merged

feat(portal): implement RBAC with permission gates and user management#526
aurelianware merged 3 commits intomainfrom
claude/implement-rbac-system-xfb2G

Conversation

@aurelianware
Copy link
Owner

Add role-based access control to the Blazor portal that restricts page access and UI elements based on the authenticated user's roles from tenant-service.

  • Add IUserContextService/UserContextService: fetches TenantUser from tenant-service, caches per circuit, expands roles to flat permissions, falls back to TenantAdmin if service unreachable
  • Add PermissionGate component: conditionally renders content based on required permissions, shows access-denied alert with role guidance
  • Wrap all 14 operational pages with PermissionGate checks: Claims/WorkQueues (claims:read), Members (members:read), Authorizations (authorizations:read), Appeals (appeals:read), Providers (providers:read), BenefitPlans (any role), PaymentRuns (payments:read), PremiumBilling (billing:read), EnrollmentOps (enrollment:read), Reports (reports:*), Settings (settings:manage), Eligibility (eligibility:check)
  • Update Dashboard welcome to show user name and primary role
  • Add User Management page at /settings/users (TenantAdmin only): list users with roles/status/last login, add/edit/disable users
  • Add AddEditUserDialog for creating and editing users
  • Add User Management nav link in MainLayout admin section
  • Register IUserContextService in Program.cs

https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz

Add role-based access control to the Blazor portal that restricts page
access and UI elements based on the authenticated user's roles from
tenant-service.

- Add IUserContextService/UserContextService: fetches TenantUser from
  tenant-service, caches per circuit, expands roles to flat permissions,
  falls back to TenantAdmin if service unreachable
- Add PermissionGate component: conditionally renders content based on
  required permissions, shows access-denied alert with role guidance
- Wrap all 14 operational pages with PermissionGate checks:
  Claims/WorkQueues (claims:read), Members (members:read),
  Authorizations (authorizations:read), Appeals (appeals:read),
  Providers (providers:read), BenefitPlans (any role),
  PaymentRuns (payments:read), PremiumBilling (billing:read),
  EnrollmentOps (enrollment:read), Reports (reports:*),
  Settings (settings:manage), Eligibility (eligibility:check)
- Update Dashboard welcome to show user name and primary role
- Add User Management page at /settings/users (TenantAdmin only):
  list users with roles/status/last login, add/edit/disable users
- Add AddEditUserDialog for creating and editing users
- Add User Management nav link in MainLayout admin section
- Register IUserContextService in Program.cs

https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz
Copilot AI review requested due to automatic review settings March 20, 2026 07:58
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements role-based access control (RBAC) in the Cloud Health Office Blazor portal by introducing a user context/permission model, a reusable permission gate component, and a TenantAdmin-only user management UI.

Changes:

  • Added IUserContextService/UserContextService to resolve tenant user roles and expand them into flattened permissions.
  • Added PermissionGate component and wrapped operational pages to enforce permission-based rendering.
  • Added /settings/users user management page and add/edit dialog; added navigation entry and DI registration.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
src/portal/CloudHealthOffice.Portal/Shared/PermissionGate.razor New component to gate UI/page rendering based on required permissions.
src/portal/CloudHealthOffice.Portal/Shared/MainLayout.razor Adds “User Management” link under Admin navigation.
src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs New user context + permission expansion + permission matching logic.
src/portal/CloudHealthOffice.Portal/Program.cs Registers IUserContextService for DI.
src/portal/CloudHealthOffice.Portal/Pages/WorkQueues.razor Wraps page content in PermissionGate for claims/work access.
src/portal/CloudHealthOffice.Portal/Pages/UserManagement.razor New TenantAdmin user list page with enable/disable and edit actions.
src/portal/CloudHealthOffice.Portal/Pages/Settings.razor Wraps settings UI in PermissionGate for settings management.
src/portal/CloudHealthOffice.Portal/Pages/Reports.razor Wraps reports UI in PermissionGate for reports permissions.
src/portal/CloudHealthOffice.Portal/Pages/Providers.razor Wraps providers UI in PermissionGate for providers read access.
src/portal/CloudHealthOffice.Portal/Pages/PremiumBilling.razor Wraps billing UI in PermissionGate for billing read access.
src/portal/CloudHealthOffice.Portal/Pages/PaymentRuns.razor Wraps payment runs UI in PermissionGate for payments read access.
src/portal/CloudHealthOffice.Portal/Pages/Members.razor Wraps members UI in PermissionGate for members read access.
src/portal/CloudHealthOffice.Portal/Pages/EnrollmentOperations.razor Wraps enrollment ops UI in PermissionGate for enrollment read access.
src/portal/CloudHealthOffice.Portal/Pages/Eligibility.razor Wraps eligibility UI in PermissionGate for eligibility checks.
src/portal/CloudHealthOffice.Portal/Pages/Dashboard.razor Wraps dashboard in PermissionGate and displays user name/primary role.
src/portal/CloudHealthOffice.Portal/Pages/Claims.razor Wraps claims UI in PermissionGate for claims access.
src/portal/CloudHealthOffice.Portal/Pages/BenefitPlans.razor Wraps benefit plan UI in PermissionGate (any role).
src/portal/CloudHealthOffice.Portal/Pages/Authorizations.razor Wraps authorizations UI in PermissionGate for auth read access.
src/portal/CloudHealthOffice.Portal/Pages/Appeals.razor Wraps appeals UI in PermissionGate for appeals read access.
src/portal/CloudHealthOffice.Portal/Pages/AddEditUserDialog.razor New dialog for creating/updating tenant users and roles.

Comment on lines +53 to +55
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string? TenantId { get; set; }
[Parameter] public UserManagement.UserListItem? ExistingUser { get; set; }
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExistingUser is typed as UserManagement.UserListItem, which couples this dialog to the UserManagement page component and makes reuse/testing harder. Consider moving UserListItem into a shared model/DTO class (e.g., under Services/ or Models/) and using that type in both components.

Suggested change
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string? TenantId { get; set; }
[Parameter] public UserManagement.UserListItem? ExistingUser { get; set; }
public class UserListItemDto
{
public string Id { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public IEnumerable<string> Roles { get; set; } = new List<string>();
}
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public string? TenantId { get; set; }
[Parameter] public UserListItemDto? ExistingUser { get; set; }

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +105
var response = await Http.PutAsJsonAsync(
$"{baseUrl}/v1/tenants/{TenantId}/users/{ExistingUser!.Id}",
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TenantId is nullable but is interpolated into the tenant-service URL. Add a submit-time guard that blocks saving and shows a clear error when TenantId is null/empty to avoid generating invalid endpoints.

Copilot uses AI. Check for mistakes.
Comment on lines +176 to +179
<MudNavLink Href="/settings/users" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ManageAccounts"
Style="border-left: 3px solid transparent; transition: all 0.3s ease;">
User Management
</MudNavLink>
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “User Management” nav link is shown to all authenticated users. Since the page is TenantAdmin-only, this link should be conditionally rendered (e.g., via PermissionGate/AuthorizeView + UserContextService.HasPermission("users:manage")) to avoid advertising admin-only functionality to non-admins.

Copilot uses AI. Check for mistakes.

try
{
var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cho-svcs/api";
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tenant-service base URL fallback (http://tenant-service.cho-svcs/api) doesn’t match the portal’s configured service URLs (appsettings.json uses http://tenant-service.cloudhealthoffice/api) and will silently mask missing configuration. Prefer requiring Services:TenantService to be set (or use the same default as appsettings) so calls don’t unexpectedly target a non-existent host.

Suggested change
var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cho-svcs/api";
var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cloudhealthoffice/api";

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +78
public async Task<UserContext?> GetCurrentUserAsync()
{
if (_loaded) return _cachedContext;
_loaded = true;

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_loaded is set to true before validating authentication/tenant context/email, so an early return null (e.g., transient tenant-context failure) permanently caches a null context for the circuit and prevents later retries. Consider only marking the context as loaded after a successful fetch, or cache a Task and retry/refresh on failure.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to 11
<PermissionGate Permission="settings:manage" RoleName="TenantAdmin">
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page is now permission-gated, but it still lacks an @attribute [Authorize] at the top. Without it, unauthenticated users can navigate directly to /settings and will see an access-denied panel rather than being challenged to sign in. Consider adding [Authorize] so routing/auth behavior is consistent with other operational pages.

Copilot uses AI. Check for mistakes.

<PageTitle>Reports & Analytics — Cloud Health Office</PageTitle>

<PermissionGate Permission="reports:claims,reports:financial,reports:compliance" RoleName="Reports access">
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RoleName is used verbatim in PermissionGate’s message as “request the <…> role”. Passing a value like "Reports access" (not an actual role name) makes the guidance misleading. Either pass a real role name (or multiple) here, or adjust PermissionGate’s wording to not assume the value is a role.

Suggested change
<PermissionGate Permission="reports:claims,reports:financial,reports:compliance" RoleName="Reports access">
<PermissionGate Permission="reports:claims,reports:financial,reports:compliance" RoleName="Reporting">

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +29
<MudText Typo="Typo.body1">
You don't have access to this page. Contact your administrator
to request the <strong>@(RoleName ?? Permission)</strong> role.
</MudText>
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Permission is left empty (as on Dashboard/BenefitPlans), the access-denied message can render an empty “role” placeholder because both RoleName and Permission may be null/empty. Consider providing a default message for the no-permission case (e.g., “request a role assignment”) or require RoleName when Permission is empty.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +75
if (ExistingUser != null)
{
_email = ExistingUser.Email;
_displayName = ExistingUser.DisplayName;
_department = ExistingUser.Department;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In edit mode, only Email/DisplayName/Department/Roles are copied from ExistingUser; first/last name fields remain empty. Since the update request later includes first/last name, this can unintentionally clear those values. Consider loading first/last name for edits (e.g., include them in the list item or fetch the user by ID).

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +102
var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cho-svcs/api";
var encodedEmail = Uri.EscapeDataString(email);
var response = await _httpClient.GetAsync(
$"{baseUrl}/v1/tenants/{tenantContext.TenantId}/users?email={encodedEmail}");

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User lookup calls GET /v1/tenants/{tenantId}/users?email=..., but tenant-service’s GET /users only supports filtering by role or department, so this likely fetches the full tenant user list and filters client-side. Consider using the existing GET .../users/by-oid/{azureAdObjectId} endpoint (claim oid) or adding an email filter endpoint to reduce data transfer and latency.

Copilot uses AI. Check for mistakes.
- Redact email addresses in UserContextService log statements to prevent
  private data exposure (security bot finding)
- Fix _loaded flag: only set to true after context is fully resolved so
  transient failures don't permanently cache null context
- Remove hardcoded tenant-service fallback URL; log warning when config
  is missing instead of silently using wrong endpoint
- Use by-oid endpoint for user lookup instead of unsupported email filter
- Add TenantId null guard in AddEditUserDialog to prevent requests with
  null tenant
- Copy FirstName/LastName from ExistingUser in edit mode to prevent
  clearing those fields; add fields to UserListItem DTO
- Conditionally render User Management and Settings nav links for
  TenantAdmin users only in MainLayout
- Add [Authorize] attribute to Settings page
- Fix Reports page RoleName to show actual role names instead of
  'Reports access'
- Handle empty Permission/RoleName in PermissionGate denied message

https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz
};

_logger.LogInformation("User context loaded for {RedactedEmail} with roles: {Roles}",
RedactEmail(email), string.Join(", ", _cachedContext.Roles));

Check warning

Code scanning / CodeQL

Exposure of private information Medium

Private data returned by
access to local variable email
is written to an external location.
Private data returned by
access to local variable email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
call to method RedactEmail
is written to an external location.

Copilot Autofix

AI 4 days ago

In general, to fix this type of issue, you should avoid sending raw or directly-derived private data (like email addresses) to log sinks. Instead, log either: (a) a non-identifying surrogate (e.g., an opaque user ID already in your system), or (b) a one-way hash / stable token that cannot be reversed to recover the original value, while still letting you correlate log entries belonging to the same user.

For this specific case, the best fix without changing overall functionality is to change RedactEmail so it no longer constructs its result by slicing the original email string. Instead, derive a deterministic, non-reversible representation based on a cryptographic hash (e.g., SHA-256) of the email, and log only that hash or a shortened version of it. This preserves the ability to correlate log entries for the same email while ensuring the logged value cannot be used to recover the actual address. Concretely:

  • Update RedactEmail in UserContextService.cs to compute a SHA-256 hash of the supplied email (using System.Security.Cryptography) and return it in a standardized format (e.g., "email-hash:" + first 8 hex chars).
  • Keep the method signature the same so existing call sites (LogInformation and LogWarning) remain unchanged.
  • Add the required using System.Security.Cryptography; and potentially using System.Text; if needed for encoding.
    This single change will address all alert variants, because the data flow from email into the log sink now passes through an irreversible transformation that no longer contains substrings of the original email.
Suggested changeset 1
src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs b/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs
--- a/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs
+++ b/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs
@@ -1,5 +1,7 @@
 using System.Security.Claims;
 using Microsoft.AspNetCore.Components.Authorization;
+using System.Security.Cryptography;
+using System.Text;
 
 namespace CloudHealthOffice.Portal.Services;
 
@@ -195,9 +197,25 @@
 
     private static string RedactEmail(string email)
     {
-        var atIndex = email.IndexOf('@');
-        if (atIndex <= 1) return "***@" + (atIndex >= 0 ? email[(atIndex + 1)..] : "***");
-        return email[0] + "***" + email[(atIndex - 1)..];
+        if (string.IsNullOrWhiteSpace(email))
+        {
+            return "email-hash:unknown";
+        }
+
+        // Use a one-way hash so that the original email address cannot be reconstructed
+        // from log files, while still allowing correlation of log entries for the same user.
+        using var sha = SHA256.Create();
+        var bytes = Encoding.UTF8.GetBytes(email);
+        var hashBytes = sha.ComputeHash(bytes);
+
+        // Represent the hash in hex and truncate to a reasonable length for logging.
+        var sb = new StringBuilder(capacity: 32);
+        for (int i = 0; i < 16 && i < hashBytes.Length; i++)
+        {
+            sb.Append(hashBytes[i].ToString("x2"));
+        }
+
+        return "email-hash:" + sb.ToString();
     }
 
     public bool HasPermission(string permission)
EOF
@@ -1,5 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Cryptography;
using System.Text;

namespace CloudHealthOffice.Portal.Services;

@@ -195,9 +197,25 @@

private static string RedactEmail(string email)
{
var atIndex = email.IndexOf('@');
if (atIndex <= 1) return "***@" + (atIndex >= 0 ? email[(atIndex + 1)..] : "***");
return email[0] + "***" + email[(atIndex - 1)..];
if (string.IsNullOrWhiteSpace(email))
{
return "email-hash:unknown";
}

// Use a one-way hash so that the original email address cannot be reconstructed
// from log files, while still allowing correlation of log entries for the same user.
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(email);
var hashBytes = sha.ComputeHash(bytes);

// Represent the hash in hex and truncate to a reasonable length for logging.
var sb = new StringBuilder(capacity: 32);
for (int i = 0; i < 16 && i < hashBytes.Length; i++)
{
sb.Append(hashBytes[i].ToString("x2"));
}

return "email-hash:" + sb.ToString();
}

public bool HasPermission(string permission)
Copilot is powered by AI and may make mistakes. Always verify output.
}

_logger.LogWarning("No active TenantUser found for {RedactedEmail} in tenant {TenantId}, using fallback",
RedactEmail(email), tenantContext.TenantId);

Check warning

Code scanning / CodeQL

Exposure of private information Medium

Private data returned by
access to local variable email
is written to an external location.
Private data returned by
access to local variable email
is written to an external location.
Private data returned by
access to local variable email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by
access to parameter email
is written to an external location.
Private data returned by call to method RedactEmail is written to an external location.

Copilot Autofix

AI 4 days ago

In general, to fix exposure of private information in logs, avoid logging raw or lightly masked PII. Either remove it entirely from log messages or replace it with a non‑reversible surrogate such as a hash or opaque identifier. This keeps logs useful for correlation while preventing anyone with log access from reconstructing the original private data.

In this specific case, the issue is the implementation of RedactEmail. CodeQL is correct that the return value is derived from the private email string and still includes recognizable portions. The best fix without changing existing behavior contracts is to change RedactEmail so it never returns any portion of the actual email address. A common pattern is to return a deterministic hash of the email encoded as hex; this allows operators to correlate repeated log entries for the same user without learning the email. We will implement RedactEmail so that:

  • If the input is null or empty, it returns "***".
  • Otherwise, it computes a SHA‑256 hash of the UTF‑8 bytes of the email and returns a string like "email_hash:<hex>".
    This keeps the method name and its callers unchanged while eliminating PII exposure. To do this we need to import System.Security.Cryptography and System.Text at the top of the file and replace the body of RedactEmail. No other code changes are necessary, and this will address all variants (1–1f) since they all trace through RedactEmail(email) to the log sink.
Suggested changeset 1
src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs b/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs
--- a/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs
+++ b/src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs
@@ -1,4 +1,6 @@
 using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
 using Microsoft.AspNetCore.Components.Authorization;
 
 namespace CloudHealthOffice.Portal.Services;
@@ -195,9 +197,22 @@
 
     private static string RedactEmail(string email)
     {
-        var atIndex = email.IndexOf('@');
-        if (atIndex <= 1) return "***@" + (atIndex >= 0 ? email[(atIndex + 1)..] : "***");
-        return email[0] + "***" + email[(atIndex - 1)..];
+        if (string.IsNullOrEmpty(email))
+        {
+            return "***";
+        }
+
+        // Use a one-way hash so logs can be correlated without exposing the actual email.
+        using var sha256 = SHA256.Create();
+        var bytes = Encoding.UTF8.GetBytes(email);
+        var hashBytes = sha256.ComputeHash(bytes);
+        var sb = new StringBuilder(hashBytes.Length * 2);
+        foreach (var b in hashBytes)
+        {
+            sb.Append(b.ToString("x2"));
+        }
+
+        return "email_hash:" + sb.ToString();
     }
 
     public bool HasPermission(string permission)
EOF
@@ -1,4 +1,6 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Components.Authorization;

namespace CloudHealthOffice.Portal.Services;
@@ -195,9 +197,22 @@

private static string RedactEmail(string email)
{
var atIndex = email.IndexOf('@');
if (atIndex <= 1) return "***@" + (atIndex >= 0 ? email[(atIndex + 1)..] : "***");
return email[0] + "***" + email[(atIndex - 1)..];
if (string.IsNullOrEmpty(email))
{
return "***";
}

// Use a one-way hash so logs can be correlated without exposing the actual email.
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(email);
var hashBytes = sha256.ComputeHash(bytes);
var sb = new StringBuilder(hashBytes.Length * 2);
foreach (var b in hashBytes)
{
sb.Append(b.ToString("x2"));
}

return "email_hash:" + sb.ToString();
}

public bool HasPermission(string permission)
Copilot is powered by AI and may make mistakes. Always verify output.
…tance

MudBlazor 6.14.0 uses the concrete class MudDialogInstance, not the
IMudDialogInstance interface (introduced in v7). This matches the pattern
used by all other dialog components in the project.

https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz
@github-actions
Copy link

Code Coverage

Package Line Rate Branch Rate Health
CloudHealthOffice.Portal 8% 3%
CloudHealthOffice.Portal 8% 3%
Summary 8% (1852 / 24322) 3% (202 / 6624)

@aurelianware aurelianware merged commit 42a75f4 into main Mar 20, 2026
59 checks passed
@aurelianware aurelianware deleted the claude/implement-rbac-system-xfb2G branch March 20, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants