Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@
"src/Tests/Features/Chatbot/AiChatMessageWireContractTests.cs",
"src/Tests/Features/Chatbot/AiChatPanelThemeUITests.cs",
"src/Tests/Features/Chatbot/AiChatPanelReadAloudUITests.cs",
"src/Tests/Features/Chatbot/AiChatPanelDictationUITests.cs",
"src/Tests/Features/Chatbot/AiChatPanelTestBase.cs",
"src/Tests/Features/Chatbot/AppChatbotHistoryTests.cs",
"src/Tests/Features/Chatbot/TestChatClient.cs"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public partial class AppClientCoordinator : AppComponentBase
//#if (notification == true)
[AutoInject] private IPushNotificationService pushNotificationService = default!;
//#endif
//#if (brouter == true)
[AutoInject] private IBrouter brouter = default!;
//#endif

private List<Action> unsubscribes = [];

Expand Down Expand Up @@ -75,16 +78,8 @@ protected override async Task OnInitAsync()

if (AppPlatform.IsBlazorHybrid is false)
{
try
{
BitButil.UseFastInvoke(); // Ensures that `TelemetryContext.Platform` is available to components using this value in their `OnInitAsync` method, such as `SignInPage.razor.cs`.
var userAgentData = await userAgent.Extract();
TelemetryContext.Platform = string.Join(' ', [userAgentData.Manufacturer, userAgentData.OsName, userAgentData.Name, "browser"]);
}
finally
{
BitButil.UseNormalInvoke();
}
var userAgentData = await userAgent.Extract();
TelemetryContext.Platform = string.Join(' ', [userAgentData.Manufacturer, userAgentData.OsName, userAgentData.Name, "browser"]);
}
TelemetryContext.TimeZone = await jsRuntime.GetTimeZone();
TelemetryContext.Culture = CultureInfo.CurrentCulture.Name;
Expand Down Expand Up @@ -146,9 +141,9 @@ private void NavigationManager_LocationChanged(object? sender, LocationChangedEv
navigatorLogger.LogInformation("Navigator's location changed to {Location}", TelemetryContext.PageUrl);
}

private Guid? lastPropagatedUserId = Guid.Empty;
private ClaimsPrincipal? lastPropagatedUser;
/// <summary>
/// This code manages the association of a user with sensitive services, such as SignalR, push notifications, App Insights, and others,
/// This code manages the association of a user with sensitive services, such as SignalR, push notifications, App Insights, and others,
/// ensuring the user is correctly set or cleared as needed.
/// </summary>
public async Task PropagateAuthState(bool firstRun, Task<AuthenticationState> task)
Expand All @@ -158,9 +153,19 @@ public async Task PropagateAuthState(bool firstRun, Task<AuthenticationState> ta
var user = (await task).User;
var isAuthenticated = user.IsAuthenticated();
var userId = isAuthenticated ? user.GetUserId() : (Guid?)null;
if (lastPropagatedUserId == userId)

if (user.IsTheSame(lastPropagatedUser))
return;

await Abort(); // Cancels ongoing user id propagation, because the new authentication state is available.

//#if (brouter == true)
// KeepAlive routes are hidden rather than disposed, so a retained page would otherwise hand the next
// principal the previous one's search text and grid filters. This is what makes a full page reload
// unnecessary after a tenant switch (see NavigationManagerExtensions.RefreshCurrentPage).
brouter.ClearKeepAlive();
//#endif

TelemetryContext.UserId = userId;
TelemetryContext.UserSessionId = isAuthenticated ? user.GetSessionId() : null;

Expand Down Expand Up @@ -204,7 +209,7 @@ public async Task PropagateAuthState(bool firstRun, Task<AuthenticationState> ta
await UpdateUserSession();
}

lastPropagatedUserId = userId;
lastPropagatedUser = user;
}
catch (Exception exp)
{
Expand Down Expand Up @@ -249,9 +254,9 @@ private void SubscribeToSignalRSharedAppMessages()
}
else
{
if (data is not null) return false; // Snack bar service does not support payload data. It would be a good idea to return false to the server so server knows that the message was not shown.

SnackBarService.Show("Boilerplate", message);

return data is null; // Snack bar service does not support payload data. It would be a good idea to return false to the server so server knows that the message was not shown properly.
}

return true; // Message gets shown successfully. You CAN (not implemented yet) use this in server side in order to not to send push notifications for messages that are already shown in the client side.
Expand Down Expand Up @@ -374,6 +379,10 @@ private async Task HubConnectionStateChange(Exception? exception)
{
logger.LogInformation("SignalR state changed to {State}", hubConnection!.State);
}
else if (exception is OperationCanceledException)
{
logger.LogInformation("SignalR connection attempt cancelled.");
}
else
{
logger.LogWarning(exception, "SignalR connection lost.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,12 @@ protected async Task Abort()
await currentCts.TryCancel();
}

private bool disposed;
public async ValueTask DisposeAsync()
{
if (disposed) return;
disposed = true;

try
{
if (cts != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace Boilerplate.Client.Core.Components;

/// <summary>
/// To implement forms where each error is displayed according to the language chosen by the user, you can use the <see cref="DtoResourceTypeAttribute"/>
/// on the corresponding class instead of using `<see cref="ValidationAttribute.ErrorResourceType" />` on each property. Check out <see cref="SignUpRequestDto.UserName"/> for an example.
/// on the corresponding class instead of setting `<see cref="ValidationAttribute.ErrorMessageResourceType" />` on each property. Check out <see cref="SignUpRequestDto.UserName"/> for an example.
/// A property opts out simply by setting it: the DTO's resource type is only injected where <see cref="ValidationAttribute.ErrorMessageResourceType"/> is still null.
/// However, you need to use <see cref="AppDataAnnotationsValidator"/> instead of <see cref="DataAnnotationsValidator"/> in Blazor EditForms for this method to work.
/// Additionally, this provides the `<see cref="DisplayErrors(ResourceValidationException)"/>` method, which assists in displaying dynamic error messages.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Boilerplate.Client.Core.Components.Layout;
public partial class AppAiChatPanel
{
[AutoInject] private SpeechRecognition speechRecognition = default!;
[AutoInject] private IPermissionService permissionService = default!;
[AutoInject] private ILogger<AppAiChatPanel> logger = default!;


Expand Down Expand Up @@ -35,6 +36,13 @@ private async Task ToggleDictation()
return;
}

if (await permissionService.RequestMicrophonePermission() is false)
{
SnackBarService.Error(Localizer[nameof(AppStrings.AiChatPanelDictation)],
Localizer[nameof(AppStrings.AiChatPanelMicrophoneBlocked)]);
return;
}

// An open microphone and a speaking answer talk over each other, and the engine's own voice is what the
// recognizer would hear. Read aloud itself stays on: it is the next answer the user wants read, not this one.
await PauseReadAloud();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public partial class AppMenu
[AutoInject] private IUserController userController = default!;
[AutoInject] private CultureInfoManager cultureInfoManager = default!;
[AutoInject] private SignInModalService signInModalService = default!;
//#if (brouter == true && multitenant == true)
[AutoInject] private IBrouter brouter = default!;
//#endif


private bool isOpen;
Expand Down Expand Up @@ -91,6 +94,10 @@ private async Task OnTenantChanged(string? tenantId)
// Switching calls the refresh token api that stores the new tenant id in the token's claims (See IdentityController.Refresh).
if (await AuthManager.SwitchTenant(newTenantId, CurrentCancellationToken))
{
//#if (brouter == true)
brouter.ClearKeepAlive();
//#endif

NavigationManager.RefreshCurrentPage(); // Re-renders the current page so it reflects the new tenant's data.
// The layout's tenant display (next to the app version) updates on its own: switching changes the tenant claim, which
// triggers the authentication-state change that MainLayout re-resolves the current tenant from (See MainLayout.SetCurrentTenantIfNeeded).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ private async void AuthManager_AuthenticationStateChanged(Task<AuthenticationSta
}
}

private ClaimsPrincipal? lastAuthUser;
private async Task SetCurrentUser(Task<AuthenticationState> task)
{
var authUser = (await task).User;
Expand All @@ -173,7 +174,7 @@ private async Task SetCurrentUser(Task<AuthenticationState> task)
}
else
{
if (authUser.GetUserId() != currentUser?.Id)
if (currentUser is null || authUser.IsTheSame(lastAuthUser) is false)
{
currentUser = await userController.GetCurrentUser(getCurrentUserCts.Token);
}
Expand All @@ -182,6 +183,8 @@ private async Task SetCurrentUser(Task<AuthenticationState> task)
await SetCurrentTenantIfNeeded(authUser.GetTenantId(), getCurrentUserCts.Token);
//#endif
}

lastAuthUser = authUser;
}

//#if (multitenant == true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,74 @@ protected override async Task OnInitAsync()
{
DataGridStrings = new()
{
PagerPageFormat = $"{Localizer[nameof(AppStrings.Page)]} {{0}} {Localizer[nameof(AppStrings.Of)]} {{1}}",
PagerRangeFormat = $"{{0}}–{{1}} {Localizer[nameof(AppStrings.Of)]} {{2}}"
EmptyText = Localizer[nameof(AppStrings.DataGridEmptyText)],
LoadingText = Localizer[nameof(AppStrings.DataGridLoadingText)],
ActionsText = Localizer[nameof(AppStrings.DataGridActionsText)],
AddRowText = Localizer[nameof(AppStrings.DataGridAddRowText)],
ClearFiltersText = Localizer[nameof(AppStrings.DataGridClearFiltersText)],
ExportCsvText = Localizer[nameof(AppStrings.DataGridExportCsvText)],
ExportExcelText = Localizer[nameof(AppStrings.DataGridExportExcelText)],
ColumnsText = Localizer[nameof(AppStrings.DataGridColumnsText)],
EditText = Localizer[nameof(AppStrings.DataGridEditText)],
DeleteText = Localizer[nameof(AppStrings.DataGridDeleteText)],
SaveText = Localizer[nameof(AppStrings.DataGridSaveText)],
CancelText = Localizer[nameof(AppStrings.DataGridCancelText)],
SelectAllLabel = Localizer[nameof(AppStrings.DataGridSelectAllLabel)],
SelectRowLabel = Localizer[nameof(AppStrings.DataGridSelectRowLabel)],
SelectRowFormat = Localizer[nameof(AppStrings.DataGridSelectRowFormat)],
FilterPlaceholder = Localizer[nameof(AppStrings.DataGridFilterPlaceholder)],
FilterByFormat = Localizer[nameof(AppStrings.DataGridFilterByFormat)],
FilterAllText = Localizer[nameof(AppStrings.DataGridFilterAllText)],
FilterOperatorLabel = Localizer[nameof(AppStrings.DataGridFilterOperatorLabel)],
FilterOpContains = Localizer[nameof(AppStrings.DataGridFilterOpContains)],
FilterOpDoesNotContain = Localizer[nameof(AppStrings.DataGridFilterOpDoesNotContain)],
FilterOpStartsWith = Localizer[nameof(AppStrings.DataGridFilterOpStartsWith)],
FilterOpEndsWith = Localizer[nameof(AppStrings.DataGridFilterOpEndsWith)],
BooleanTrueText = Localizer[nameof(AppStrings.DataGridBooleanTrueText)],
BooleanFalseText = Localizer[nameof(AppStrings.DataGridBooleanFalseText)],
GroupByFormat = Localizer[nameof(AppStrings.DataGridGroupByFormat)],
UngroupByFormat = Localizer[nameof(AppStrings.DataGridUngroupByFormat)],
ExpandGroupFormat = Localizer[nameof(AppStrings.DataGridExpandGroupFormat)],
CollapseGroupFormat = Localizer[nameof(AppStrings.DataGridCollapseGroupFormat)],
PagerRangeFormat = Localizer[nameof(AppStrings.DataGridPagerRangeFormat)],
PagerPageFormat = Localizer[nameof(AppStrings.DataGridPagerPageFormat)],
PerPageFormat = Localizer[nameof(AppStrings.DataGridPerPageFormat)],
RowsPerPageLabel = Localizer[nameof(AppStrings.DataGridRowsPerPageLabel)],
FirstPageLabel = Localizer[nameof(AppStrings.DataGridFirstPageLabel)],
PreviousPageLabel = Localizer[nameof(AppStrings.DataGridPreviousPageLabel)],
NextPageLabel = Localizer[nameof(AppStrings.DataGridNextPageLabel)],
LastPageLabel = Localizer[nameof(AppStrings.DataGridLastPageLabel)],
EndOfResultsText = Localizer[nameof(AppStrings.DataGridEndOfResultsText)],
ToggleDetailsLabel = Localizer[nameof(AppStrings.DataGridToggleDetailsLabel)],
ToggleChildrenLabel = Localizer[nameof(AppStrings.DataGridToggleChildrenLabel)],
ReorderRowTitle = Localizer[nameof(AppStrings.DataGridReorderRowTitle)],
ReorderRowLabel = Localizer[nameof(AppStrings.DataGridReorderRowLabel)],
InvalidValueError = Localizer[nameof(AppStrings.DataGridInvalidValueError)],
AnnouncementSortedAscending = Localizer[nameof(AppStrings.DataGridAnnouncementSortedAscending)],
AnnouncementSortedDescending = Localizer[nameof(AppStrings.DataGridAnnouncementSortedDescending)],
AnnouncementSortCleared = Localizer[nameof(AppStrings.DataGridAnnouncementSortCleared)],
AnnouncementFiltered = Localizer[nameof(AppStrings.DataGridAnnouncementFiltered)],
AnnouncementFilterCleared = Localizer[nameof(AppStrings.DataGridAnnouncementFilterCleared)],
AnnouncementPage = Localizer[nameof(AppStrings.DataGridAnnouncementPage)],
AnnouncementRowDeleted = Localizer[nameof(AppStrings.DataGridAnnouncementRowDeleted)]
};

await base.OnInitAsync();
}

protected override async Task OnAfterRenderAsync(bool firstRender)
protected override async Task OnParamsSetAsync()
{
await base.OnAfterRenderAsync(firstRender);
await base.OnParamsSetAsync();

if (firstRender)
if (InPrerenderSession) return;

if (string.IsNullOrEmpty(culture)) return;

if (CultureInfoManager.InvariantGlobalization || CultureInfoManager.SupportedCultures.Any(sc => string.Equals(sc.Culture.Name, culture, StringComparison.InvariantCultureIgnoreCase)) is false)
{
if (string.IsNullOrEmpty(culture) is false)
{
if (CultureInfoManager.InvariantGlobalization || CultureInfoManager.SupportedCultures.Any(sc => string.Equals(sc.Culture.Name, culture, StringComparison.InvariantCultureIgnoreCase)) is false)
{
// Because Blazor router doesn't support regex, the '/{culture?}/' captures some irrelevant routes
// such as non existing routes like /some-invalid-url, we need to make sure that the first segment of the route is a valid culture name.
NavigationManager.NavigateTo($"{PageUrls.NotFound}?url={Uri.EscapeDataString(NavigationManager.GetRelativePath())}", replace: true);
}
}
// Because Blazor router doesn't support regex, the '/{culture?}/' captures some irrelevant routes
// such as non existing routes like /some-invalid-url, we need to make sure that the first segment of the route is a valid culture name.
NavigationManager.NavigateTo($"{PageUrls.NotFound}?url={Uri.EscapeDataString(NavigationManager.GetRelativePath())}", replace: true);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,20 @@ private async Task HandleOnSelectRole(BitNavItem? item)
if (loadRoleDataCts is not null)
{
using var currentCts = loadRoleDataCts;
loadRoleDataCts = new();
loadRoleDataCts = null;

await currentCts.TryCancel();
}

loadRoleDataCts = new();
var loadCts = loadRoleDataCts = new();

selectedRoleItem = item;
loadingRoleKey = item.Key;
editRoleName = selectedRoleItem.Text;

var id = Guid.Parse(item.Key!);
await Task.WhenAll(LoadRoleUsers(id, loadRoleDataCts.Token),
LoadRoleClaims(id, loadRoleDataCts.Token));
await Task.WhenAll(LoadRoleUsers(id, loadCts),
LoadRoleClaims(id, loadCts));
}
finally
{
Expand All @@ -121,14 +121,22 @@ await Task.WhenAll(LoadRoleUsers(id, loadRoleDataCts.Token),
}
}

private async Task LoadRoleUsers(Guid roleId, CancellationToken cancellationToken)
private async Task LoadRoleUsers(Guid roleId, CancellationTokenSource cancellationToken)
{
selectedRoleUsers = await roleManagementController.GetUsers(roleId, cancellationToken);
var roleUsers = await roleManagementController.GetUsers(roleId, cancellationToken.Token);

if (ReferenceEquals(loadRoleDataCts, cancellationToken) is false) return; // User tapped on another role while this one was loading, so ignore the results

selectedRoleUsers = roleUsers;
}

private async Task LoadRoleClaims(Guid roleId, CancellationToken cancellationToken)
private async Task LoadRoleClaims(Guid roleId, CancellationTokenSource cancellationToken)
{
selectedRoleClaims = await roleManagementController.GetClaims(roleId, cancellationToken);
var roleClaims = await roleManagementController.GetClaims(roleId, cancellationToken.Token);

if (ReferenceEquals(loadRoleDataCts, cancellationToken) is false) return; // User tapped on another role while this one was loading, so ignore the results

selectedRoleClaims = roleClaims;

var maxPrivilegedSessionsValue = selectedRoleClaims.FirstOrDefault(c => c.ClaimType == AppClaimTypes.MAX_PRIVILEGED_SESSIONS)?.ClaimValue;
maxPrivilegedSessions = maxPrivilegedSessionsValue is null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,24 @@ private async Task HandleOnSelectUser(BitNavItem? item)
{
if (loadRoleDataCts is not null)
{
using var currentCts = loadRoleDataCts;
loadRoleDataCts = new();

await currentCts.TryCancel();
using var previousCts = loadRoleDataCts;
loadRoleDataCts = null;
await previousCts.TryCancel();
}

loadRoleDataCts = new();
var loadCts = loadRoleDataCts = new();

loadingUserKey = item.Key;
selectedUserItem = item;
var user = (item.Data as UserDto)!;

user.Patch(selectedUserDto);

allUserSessions = await userManagementController.GetUserSessions(user.Id, CurrentCancellationToken);
var userSessions = await userManagementController.GetUserSessions(user.Id, loadCts.Token);

if (ReferenceEquals(loadRoleDataCts, loadCts) is false) return; // Selected user changed while we were loading, so don't assign the sessions to the previous user.

allUserSessions = userSessions;

SearchSessions();
}
Expand Down
Loading
Loading