From 48df4f6b02a78e02ce547e2552ecf7fefe69e069 Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Mon, 17 Aug 2026 19:28:30 +0200 Subject: [PATCH] add native permission management to bit Boilerplate (#12913) --- .../.template.config/template.json | 1 + .../Components/AppClientCoordinator.cs | 41 +++-- .../Components/AppComponentBase.cs | 4 + .../Components/AppDataAnnotationsValidator.cs | 3 +- .../AppAiChatPanel.razor.SpeechRecognition.cs | 8 + .../Components/Layout/Header/AppMenu.razor.cs | 7 + .../Components/Layout/MainLayout.razor.cs | 5 +- .../Components/Pages/AppPageBase.cs | 74 +++++++-- .../Pages/Management/RolesPage.razor.cs | 24 ++- .../Pages/Management/UsersPage.razor.cs | 15 +- .../Components/Parameters.cs | 6 +- .../Components/Routes.razor.cs | 40 ++--- .../Extensions/NavigationManagerExtensions.cs | 2 +- .../Services/Contracts/IPermissionService.cs | 17 ++ .../Services/MauiPermissionService.cs | 34 ++++ .../MauiProgram.Services.cs | 1 + .../Services/WebPermissionService.cs | 13 ++ .../Program.Services.cs | 1 + .../Services/WindowsPermissionService.cs | 13 ++ .../Program.Services.cs | 1 + .../Extensions/ClaimsPrincipalExtensions.cs | 42 +++++ .../src/Shared/Resources/AppStrings.ar.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.de.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.es.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.fa.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.fr.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.hi.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.nl.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.sv.resx | 151 ++++++++++++++++++ .../src/Shared/Resources/AppStrings.zh.resx | 151 ++++++++++++++++++ .../TemplateConfigurationTests.cs | 138 +++++++++++++++- 32 files changed, 1926 insertions(+), 74 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/Contracts/IPermissionService.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Infrastructure/Services/MauiPermissionService.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Infrastructure/Services/WebPermissionService.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Infrastructure/Services/WindowsPermissionService.cs diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json index a800f6627da..580ac10d236 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json @@ -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" diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs index b08d27c8449..caf811c9cc9 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppClientCoordinator.cs @@ -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 unsubscribes = []; @@ -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; @@ -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; /// - /// 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. /// public async Task PropagateAuthState(bool firstRun, Task task) @@ -158,9 +153,19 @@ public async Task PropagateAuthState(bool firstRun, Task 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; @@ -204,7 +209,7 @@ public async Task PropagateAuthState(bool firstRun, Task ta await UpdateUserSession(); } - lastPropagatedUserId = userId; + lastPropagatedUser = user; } catch (Exception exp) { @@ -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. @@ -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."); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs index f55b68238cf..5ddb261b39b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppComponentBase.cs @@ -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) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppDataAnnotationsValidator.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppDataAnnotationsValidator.cs index 34d54017fe4..8bc82981bf2 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppDataAnnotationsValidator.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppDataAnnotationsValidator.cs @@ -5,7 +5,8 @@ namespace Boilerplate.Client.Core.Components; /// /// To implement forms where each error is displayed according to the language chosen by the user, you can use the -/// on the corresponding class instead of using `` on each property. Check out for an example. +/// on the corresponding class instead of setting `` on each property. Check out for an example. +/// A property opts out simply by setting it: the DTO's resource type is only injected where is still null. /// However, you need to use instead of in Blazor EditForms for this method to work. /// Additionally, this provides the `` method, which assists in displaying dynamic error messages. /// diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.SpeechRecognition.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.SpeechRecognition.cs index c5d5cd5a889..c8205416a93 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.SpeechRecognition.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppAiChatPanel.razor.SpeechRecognition.cs @@ -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 logger = default!; @@ -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(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/AppMenu.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/AppMenu.razor.cs index 6adcc54ec98..e28574ac95a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/AppMenu.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Header/AppMenu.razor.cs @@ -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; @@ -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). diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs index 8a11fc8f9d7..f94267fd872 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.cs @@ -154,6 +154,7 @@ private async void AuthManager_AuthenticationStateChanged(Task task) { var authUser = (await task).User; @@ -173,7 +174,7 @@ private async Task SetCurrentUser(Task task) } else { - if (authUser.GetUserId() != currentUser?.Id) + if (currentUser is null || authUser.IsTheSame(lastAuthUser) is false) { currentUser = await userController.GetCurrentUser(getCurrentUserCts.Token); } @@ -182,6 +183,8 @@ private async Task SetCurrentUser(Task task) await SetCurrentTenantIfNeeded(authUser.GetTenantId(), getCurrentUserCts.Token); //#endif } + + lastAuthUser = authUser; } //#if (multitenant == true) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/AppPageBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/AppPageBase.cs index d66146a8b40..bfbee8ab5fd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/AppPageBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/AppPageBase.cs @@ -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); } } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/RolesPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/RolesPage.razor.cs index 6984c8e4feb..6545344ddca 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/RolesPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/RolesPage.razor.cs @@ -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 { @@ -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 diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs index 610d8c960eb..8e599898307 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Management/UsersPage.razor.cs @@ -104,13 +104,12 @@ 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; @@ -118,7 +117,11 @@ private async Task HandleOnSelectUser(BitNavItem? item) 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(); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Parameters.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Parameters.cs index 4be256c60eb..5704983a019 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Parameters.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Parameters.cs @@ -3,11 +3,11 @@ namespace Boilerplate.Client.Core.Components; public class Parameters { /// - /// Indicates the connection status, with default behavior tied to the SignalR connection status. - /// allows this value to be updated based on server responses: + /// Indicates the connection status. drives it from server responses: /// - When the first response is received from the server, this value becomes true (Online). /// - When a occurs, it becomes false (Offline). - /// By default, this value is null (Unknown). + /// By default, this value is null (Unknown), and it only changes when a request is actually made - nothing polls. + /// When the app is built with SignalR, the hub connection state drives it as well (see AppClientCoordinator). /// public const string IsOnline = nameof(IsOnline); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Routes.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Routes.razor.cs index f0dc7bdaf73..9d1c36488bb 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Routes.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Routes.razor.cs @@ -5,35 +5,37 @@ public partial class Routes { [Parameter] public Type? Layout { get; set; } - [AutoInject] NavigationManager? navigationManager { set => universalLinksNavigationManager = value; get => universalLinksNavigationManager; } - private static NavigationManager? universalLinksNavigationManager; + [AutoInject] + NavigationManager? navigationManager + { + set + { + if (value is not null) + { + current = value; + NavigationManagerProvider.TrySetResult(); + } + } + get; + } + private static NavigationManager? current; + private static readonly TaskCompletionSource NavigationManagerProvider = new(TaskCreationOptions.RunContinuationsAsynchronously); public static async Task OpenUniversalLink(string url, bool forceLoad = false, bool replace = false) { - await EnsureNavigationManagerIsReady(); + await NavigationManagerProvider.Task; + + var navigationManager = current!; if (CultureInfoManager.InvariantGlobalization is false && forceLoad == false && (AppPlatform.IsAndroid || AppPlatform.IsIos)) { - var currentCulture = CultureInfo.CurrentUICulture.Name; - var uri = new Uri(url); - var urlCulture = uri.GetCulture(); - forceLoad = urlCulture is not null && string.Equals(currentCulture, urlCulture, StringComparison.InvariantCultureIgnoreCase) is false; + var urlCulture = new Uri(new Uri(navigationManager.BaseUri), url).GetCulture(); + forceLoad = urlCulture is not null && string.Equals(CultureInfo.CurrentUICulture.Name, urlCulture, StringComparison.InvariantCultureIgnoreCase) is false; } - universalLinksNavigationManager!.NavigateTo(url, forceLoad, replace); - } - - private static async Task EnsureNavigationManagerIsReady() - { - await Task.Run(async () => - { - while (universalLinksNavigationManager is null) - { - await Task.Yield(); - } - }); + navigationManager.NavigateTo(url, forceLoad, replace); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Extensions/NavigationManagerExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Extensions/NavigationManagerExtensions.cs index e2127cb9cbe..2c5adad35aa 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Extensions/NavigationManagerExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Extensions/NavigationManagerExtensions.cs @@ -25,7 +25,7 @@ public string GetRelativePath() /// public void RefreshCurrentPage() { - navigationManager.NavigateTo(navigationManager.GetUriPath(), forceLoad: true, replace: true); + navigationManager.NavigateTo(navigationManager.GetUriPath(), forceLoad: false, replace: true); } } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/Contracts/IPermissionService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/Contracts/IPermissionService.cs new file mode 100644 index 00000000000..a3b4105e20c --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Infrastructure/Services/Contracts/IPermissionService.cs @@ -0,0 +1,17 @@ +namespace Boilerplate.Client.Core.Infrastructure.Services.Contracts; + +/// +/// Asks the operating system for a capability the app is about to use. +/// +/// Only the hosts that wrap the app in a native shell have anything to do here: a browser prompts for itself the +/// first time a web API needs the capability, whereas a web view inside a native app is refused by the platform +/// before its own prompt is ever reached, so the native permission has to be granted first. +/// +/// +public interface IPermissionService +{ + /// + /// Whether the app may use the microphone, asking the user if the platform has not been answered already. + /// + Task RequestMicrophonePermission(); +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Infrastructure/Services/MauiPermissionService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Infrastructure/Services/MauiPermissionService.cs new file mode 100644 index 00000000000..90faa0e56dc --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Infrastructure/Services/MauiPermissionService.cs @@ -0,0 +1,34 @@ +namespace Boilerplate.Client.Maui.Infrastructure.Services; + +/// +/// More info at +/// +/// The web view is inside a native app, so the platform refuses the capability to the page unless the app itself +/// holds it. Every permission asked for here has to be declared in the platform's manifest as well - Android's +/// AndroidManifest.xml, the Info.plist of iOS and macOS, and Package.appxmanifest on Windows - and a missing +/// declaration is a build-time configuration mistake that MAUI reports by throwing rather than by refusing. +/// +/// +public partial class MauiPermissionService : IPermissionService +{ + public async Task RequestMicrophonePermission() + { + return await RequestPermission(); + } + + /// + /// The status is checked before it is requested because a request is only ever answered by the user once: after + /// a refusal the platform returns Denied without showing anything, and on the platforms where it does show the + /// dialog, asking for something already granted would put it in front of the user again. + /// + private static async Task RequestPermission() + where TPermission : Permissions.BasePermission, new() + { + if (await Permissions.CheckStatusAsync() is PermissionStatus.Granted) + return true; + + var status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync); + + return status is PermissionStatus.Granted; + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/MauiProgram.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/MauiProgram.Services.cs index 2caa4d586fb..226f887f997 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/MauiProgram.Services.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/MauiProgram.Services.cs @@ -32,6 +32,7 @@ public void ConfigureServices() services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Infrastructure/Services/WebPermissionService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Infrastructure/Services/WebPermissionService.cs new file mode 100644 index 00000000000..aa8a9f92ea0 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Infrastructure/Services/WebPermissionService.cs @@ -0,0 +1,13 @@ +namespace Boilerplate.Client.Web.Infrastructure.Services; + +/// +/// More info at +/// +/// The browser owns its permissions: the web API that needs one raises the prompt itself and reports a refusal back +/// through its own error path, so there is nothing to ask for ahead of time. +/// +/// +public partial class WebPermissionService : IPermissionService +{ + public Task RequestMicrophonePermission() => Task.FromResult(true); +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.Services.cs index c597868254f..c32e7dac151 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.Services.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.Services.cs @@ -69,6 +69,7 @@ public void AddClientWebProjectServices(IConfiguration configuration) //#endif services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(sp => sp.GetRequiredService>().Value); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Infrastructure/Services/WindowsPermissionService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Infrastructure/Services/WindowsPermissionService.cs new file mode 100644 index 00000000000..47b1cf4b4f0 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Infrastructure/Services/WindowsPermissionService.cs @@ -0,0 +1,13 @@ +namespace Boilerplate.Client.Windows.Infrastructure.Services; + +/// +/// More info at +/// +/// The app is the web view: what the page asks for arrives as WebView2's PermissionRequested, which Program.cs +/// grants, and Windows itself gates the device behind its own privacy settings. There is nothing left to ask. +/// +/// +public partial class WindowsPermissionService : IPermissionService +{ + public Task RequestMicrophonePermission() => Task.FromResult(true); +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Program.Services.cs index 62b9f86af58..18d02842654 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Program.Services.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Windows/Program.Services.cs @@ -29,6 +29,7 @@ public void AddClientWindowsProjectServices(IConfiguration configuration) services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(sp => diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Infrastructure/Extensions/ClaimsPrincipalExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Infrastructure/Extensions/ClaimsPrincipalExtensions.cs index 06c052d2831..02cebeaa6c1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Infrastructure/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Infrastructure/Extensions/ClaimsPrincipalExtensions.cs @@ -3,12 +3,54 @@ namespace System.Security.Claims; public static partial class ClaimsPrincipalExtensions { + /// + /// Claims a token refresh rewrites without changing anything the user is allowed to do: issued-at, not-before, + /// expiry and the token's own id. Everything else the token carries - the user id, the roles, the features, the + /// tenant, the session id, and the privileged/elevated markers - decides what the app may show and what the + /// server will authorize, so all of it is significant. + /// + private static string[]? volatileClaimTypes; + extension(ClaimsPrincipal? claimsPrincipal) { public bool IsAuthenticated() { return claimsPrincipal?.Identity?.IsAuthenticated is true; } + + /// + /// True when two principals grant exactly the same thing - same identity, same roles, same features, same + /// tenant, same session - ignoring only the claims a refresh rewrites for its own sake. + /// + /// This is the question every AuthenticationStateChanged handler actually wants to ask. Comparing the + /// user id alone is not enough: a tenant switch, an elevated-access grant and a role change all issue a new + /// token for the SAME user, and a handler keyed on the user id treats them as "nothing happened". + /// + /// + public bool IsTheSame(ClaimsPrincipal? other) + { + if (ReferenceEquals(claimsPrincipal, other)) return true; + + if (claimsPrincipal is null || other is null) return false; + + if (claimsPrincipal.IsAuthenticated() != other.IsAuthenticated()) return false; + + volatileClaimTypes ??= ["exp", "iat", "nbf", "jti", "auth_time"]; + + static Dictionary<(string Type, string Value), int> SignificantClaims(ClaimsPrincipal principal) + { + return principal.Claims + .Where(claim => volatileClaimTypes.Contains(claim.Type, StringComparer.OrdinalIgnoreCase) is false) + .GroupBy(claim => (claim.Type, claim.Value)) + .ToDictionary(group => group.Key, group => group.Count()); + } + + var current = SignificantClaims(claimsPrincipal); + var others = SignificantClaims(other); + + return current.Count == others.Count && + current.All(claim => others.TryGetValue(claim.Key, out var count) && count == claim.Value); + } } extension(ClaimsPrincipal claimsPrincipal) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx index cbf00ce0d5a..0e4ac92e372 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx @@ -177,6 +177,157 @@ تعذر الاتصال بالخادم + + + لا توجد سجلات للعرض. + + + جارٍ التحميل… + + + الإجراءات + + + + إضافة + + + مسح عوامل التصفية + + + تصدير CSV + + + تصدير Excel + + + الأعمدة + + + تعديل + + + حذف + + + حفظ + + + إلغاء + + + تحديد الكل + + + تحديد الصف + + + تحديد الصف: {0} + + + تصفية… + + + تصفية حسب {0} + + + الكل + + + عامل التصفية لـ {0} + + + يحتوي على + + + لا يحتوي على + + + يبدأ بـ + + + ينتهي بـ + + + صحيح + + + خطأ + + + تجميع حسب {0} + + + إلغاء التجميع حسب {0} + + + توسيع المجموعة {0}: {1} + + + طي المجموعة {0}: {1} + + + {0}–{1} من {2} + + + الصفحة {0} من {1} + + + {0} / صفحة + + + الصفوف لكل صفحة + + + الصفحة الأولى + + + الصفحة السابقة + + + الصفحة التالية + + + الصفحة الأخيرة + + + - نهاية النتائج - + + + إظهار/إخفاء التفاصيل + + + إظهار/إخفاء العناصر الفرعية + + + اسحب لإعادة الترتيب، أو ركّز واستخدم مفاتيح الأسهم لنقل هذا الصف + + + إعادة ترتيب الصف. اضغط على السهم لأعلى أو لأسفل لنقل هذا الصف. + + + قيمة غير صالحة لـ {0}. + + + مرتب حسب {0} تصاعديًا + + + مرتب حسب {0} تنازليًا + + + تمت إزالة الترتيب حسب {0} + + + تم تطبيق التصفية على {0} + + + تم مسح التصفية على {0} + + + الصفحة {0} من {1} + + + تم حذف الصف + نشط diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.de.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.de.resx index dd299f1355e..0611b6b8489 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.de.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.de.resx @@ -177,6 +177,157 @@ Verbindung zum Server nicht möglich. + + + Keine Datensätze vorhanden. + + + Wird geladen… + + + Aktionen + + + + Hinzufügen + + + Filter zurücksetzen + + + CSV exportieren + + + Excel exportieren + + + Spalten + + + Bearbeiten + + + Löschen + + + Speichern + + + Abbrechen + + + Alle auswählen + + + Zeile auswählen + + + Zeile auswählen: {0} + + + Filtern… + + + Nach {0} filtern + + + Alle + + + Filteroperator für {0} + + + Enthält + + + Enthält nicht + + + Beginnt mit + + + Endet mit + + + Wahr + + + Falsch + + + Nach {0} gruppieren + + + Gruppierung nach {0} aufheben + + + Gruppe {0} erweitern: {1} + + + Gruppe {0} reduzieren: {1} + + + {0}–{1} von {2} + + + Seite {0} von {1} + + + {0} / Seite + + + Zeilen pro Seite + + + Erste Seite + + + Vorherige Seite + + + Nächste Seite + + + Letzte Seite + + + - Ende der Ergebnisse - + + + Details ein-/ausblenden + + + Untereinträge ein-/ausblenden + + + Zum Neuanordnen ziehen oder fokussieren und die Pfeiltasten verwenden, um diese Zeile zu verschieben + + + Zeile neu anordnen. Drücken Sie Pfeil nach oben oder unten, um diese Zeile zu verschieben. + + + Ungültiger Wert für {0}. + + + Sortiert nach {0}, aufsteigend + + + Sortiert nach {0}, absteigend + + + Sortierung nach {0} entfernt + + + Filter auf {0} angewendet + + + Filter auf {0} entfernt + + + Seite {0} von {1} + + + Zeile gelöscht + Aktiv diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.es.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.es.resx index 939d4a14fd5..c582e19082c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.es.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.es.resx @@ -177,6 +177,157 @@ No se puede conectar al servidor. + + + No hay registros para mostrar. + + + Cargando… + + + Acciones + + + + Agregar + + + Borrar filtros + + + Exportar CSV + + + Exportar Excel + + + Columnas + + + Editar + + + Eliminar + + + Guardar + + + Cancelar + + + Seleccionar todo + + + Seleccionar fila + + + Seleccionar fila: {0} + + + Filtrar… + + + Filtrar por {0} + + + Todos + + + Operador de filtro para {0} + + + Contiene + + + No contiene + + + Empieza por + + + Termina en + + + Verdadero + + + Falso + + + Agrupar por {0} + + + Desagrupar por {0} + + + Expandir grupo {0}: {1} + + + Contraer grupo {0}: {1} + + + {0}–{1} de {2} + + + Página {0} de {1} + + + {0} / página + + + Filas por página + + + Primera página + + + Página anterior + + + Página siguiente + + + Última página + + + - Fin de los resultados - + + + Mostrar u ocultar detalles + + + Mostrar u ocultar elementos secundarios + + + Arrastre para reordenar, o enfoque y use las teclas de flecha para mover esta fila + + + Reordenar fila. Pulse Flecha arriba o Flecha abajo para mover esta fila. + + + Valor no válido para {0}. + + + Ordenado por {0}, ascendente + + + Ordenado por {0}, descendente + + + Se quitó la ordenación por {0} + + + Filtro aplicado en {0} + + + Se borró el filtro en {0} + + + Página {0} de {1} + + + Fila eliminada + Activo diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx index ff1d2c362e1..4360b351e52 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx @@ -176,6 +176,157 @@ قادر به اتصال به سرور نیست. + + + + رکوردی برای نمایش وجود ندارد. + + + در حال بارگذاری… + + + عملیات + + + + افزودن + + + پاک کردن فیلترها + + + خروجی CSV + + + خروجی Excel + + + ستون‌ها + + + ویرایش + + + حذف + + + ذخیره + + + انصراف + + + انتخاب همه + + + انتخاب سطر + + + انتخاب سطر: {0} + + + فیلتر… + + + فیلتر بر اساس {0} + + + همه + + + عملگر فیلتر برای {0} + + + شامل + + + شامل نیست + + + شروع می‌شود با + + + پایان می‌یابد با + + + درست + + + نادرست + + + گروه‌بندی بر اساس {0} + + + لغو گروه‌بندی بر اساس {0} + + + باز کردن گروه {0}: {1} + + + بستن گروه {0}: {1} + + + {0}–{1} از {2} + + + صفحه {0} از {1} + + + {0} / صفحه + + + سطر در هر صفحه + + + صفحه اول + + + صفحه قبل + + + صفحه بعد + + + صفحه آخر + + + - پایان نتایج - + + + نمایش/پنهان کردن جزئیات + + + نمایش/پنهان کردن زیرمجموعه‌ها + + + برای تغییر ترتیب بکشید، یا فوکوس کنید و با کلیدهای جهت‌نما این سطر را جابه‌جا کنید + + + تغییر ترتیب سطر. برای جابه‌جایی این سطر کلید بالا یا پایین را فشار دهید. + + + مقدار نامعتبر برای {0}. + + + مرتب‌شده بر اساس {0}، صعودی + + + مرتب‌شده بر اساس {0}، نزولی + + + مرتب‌سازی بر اساس {0} حذف شد + + + فیلتر روی {0} اعمال شد + + + فیلتر روی {0} پاک شد + + + صفحه {0} از {1} + + + سطر حذف شد diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fr.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fr.resx index b0a069227f3..eb93b1a1470 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fr.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fr.resx @@ -177,6 +177,157 @@ Impossible de se connecter au serveur. + + + Aucun enregistrement à afficher. + + + Chargement… + + + Actions + + + + Ajouter + + + Effacer les filtres + + + Exporter en CSV + + + Exporter en Excel + + + Colonnes + + + Modifier + + + Supprimer + + + Enregistrer + + + Annuler + + + Tout sélectionner + + + Sélectionner la ligne + + + Sélectionner la ligne : {0} + + + Filtrer… + + + Filtrer par {0} + + + Tous + + + Opérateur de filtre pour {0} + + + Contient + + + Ne contient pas + + + Commence par + + + Se termine par + + + Vrai + + + Faux + + + Grouper par {0} + + + Dégrouper par {0} + + + Développer le groupe {0} : {1} + + + Réduire le groupe {0} : {1} + + + {0}–{1} sur {2} + + + Page {0} sur {1} + + + {0} / page + + + Lignes par page + + + Première page + + + Page précédente + + + Page suivante + + + Dernière page + + + - Fin des résultats - + + + Afficher/masquer les détails + + + Afficher/masquer les éléments enfants + + + Faites glisser pour réorganiser, ou placez le focus et utilisez les touches fléchées pour déplacer cette ligne + + + Réorganiser la ligne. Appuyez sur Flèche haut ou Flèche bas pour déplacer cette ligne. + + + Valeur non valide pour {0}. + + + Trié par {0}, croissant + + + Trié par {0}, décroissant + + + Tri par {0} supprimé + + + Filtre appliqué sur {0} + + + Filtre sur {0} effacé + + + Page {0} sur {1} + + + Ligne supprimée + Actif diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.hi.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.hi.resx index 1086e9ec648..a25c869a119 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.hi.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.hi.resx @@ -177,6 +177,157 @@ सर्वर से कनेक्ट करने में असमर्थ। + + + प्रदर्शित करने के लिए कोई रिकॉर्ड नहीं है। + + + लोड हो रहा है… + + + क्रियाएँ + + + + जोड़ें + + + फ़िल्टर साफ़ करें + + + CSV निर्यात करें + + + Excel निर्यात करें + + + कॉलम + + + संपादित करें + + + हटाएँ + + + सहेजें + + + रद्द करें + + + सभी चुनें + + + पंक्ति चुनें + + + पंक्ति चुनें: {0} + + + फ़िल्टर… + + + {0} के अनुसार फ़िल्टर करें + + + सभी + + + {0} के लिए फ़िल्टर ऑपरेटर + + + शामिल है + + + शामिल नहीं है + + + से शुरू होता है + + + से समाप्त होता है + + + सत्य + + + असत्य + + + {0} के अनुसार समूहित करें + + + {0} के अनुसार समूह हटाएँ + + + समूह {0} विस्तृत करें: {1} + + + समूह {0} संक्षिप्त करें: {1} + + + {2} में से {0}–{1} + + + पृष्ठ {0} / {1} + + + {0} / पृष्ठ + + + प्रति पृष्ठ पंक्तियाँ + + + पहला पृष्ठ + + + पिछला पृष्ठ + + + अगला पृष्ठ + + + अंतिम पृष्ठ + + + - परिणामों का अंत - + + + विवरण दिखाएँ/छिपाएँ + + + उप-आइटम दिखाएँ/छिपाएँ + + + पुनः क्रमित करने के लिए खींचें, या फ़ोकस करके तीर कुंजियों से इस पंक्ति को ले जाएँ + + + पंक्ति पुनः क्रमित करें। इस पंक्ति को ले जाने के लिए ऊपर या नीचे तीर दबाएँ। + + + {0} के लिए अमान्य मान। + + + {0} के अनुसार आरोही क्रम में क्रमबद्ध + + + {0} के अनुसार अवरोही क्रम में क्रमबद्ध + + + {0} के अनुसार क्रमबद्धता हटाई गई + + + {0} पर फ़िल्टर लागू किया गया + + + {0} पर फ़िल्टर हटाया गया + + + पृष्ठ {0} / {1} + + + पंक्ति हटाई गई + सक्रिय diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx index afd295f4d61..74112135d03 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx @@ -177,6 +177,157 @@ Kan geen verbinding maken met de server. + + + Geen records om weer te geven. + + + Laden… + + + Acties + + + + Toevoegen + + + Filters wissen + + + CSV exporteren + + + Excel exporteren + + + Kolommen + + + Bewerken + + + Verwijderen + + + Opslaan + + + Annuleren + + + Alles selecteren + + + Rij selecteren + + + Rij selecteren: {0} + + + Filteren… + + + Filteren op {0} + + + Alle + + + Filteroperator voor {0} + + + Bevat + + + Bevat niet + + + Begint met + + + Eindigt op + + + Waar + + + Onwaar + + + Groeperen op {0} + + + Groepering op {0} opheffen + + + Groep {0} uitvouwen: {1} + + + Groep {0} samenvouwen: {1} + + + {0}–{1} van {2} + + + Pagina {0} van {1} + + + {0} / pagina + + + Rijen per pagina + + + Eerste pagina + + + Vorige pagina + + + Volgende pagina + + + Laatste pagina + + + - Einde van de resultaten - + + + Details tonen/verbergen + + + Onderliggende items tonen/verbergen + + + Sleep om de volgorde te wijzigen, of focus en gebruik de pijltoetsen om deze rij te verplaatsen + + + Rij herordenen. Druk op pijl omhoog of omlaag om deze rij te verplaatsen. + + + Ongeldige waarde voor {0}. + + + Gesorteerd op {0}, oplopend + + + Gesorteerd op {0}, aflopend + + + Sortering op {0} verwijderd + + + Filter toegepast op {0} + + + Filter op {0} gewist + + + Pagina {0} van {1} + + + Rij verwijderd + Actief diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx index c58ff1fe82e..bcc568d20ec 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -176,6 +176,157 @@ Unable to connect to server. + + + + No records to display. + + + Loading… + + + Actions + + + + Add + + + Clear filters + + + Export CSV + + + Export Excel + + + Columns + + + Edit + + + Delete + + + Save + + + Cancel + + + Select all + + + Select row + + + Select row: {0} + + + Filter… + + + Filter by {0} + + + All + + + Filter operator for {0} + + + Contains + + + Doesn't contain + + + Starts with + + + Ends with + + + True + + + False + + + Group by {0} + + + Ungroup by {0} + + + Expand group {0}: {1} + + + Collapse group {0}: {1} + + + {0}–{1} of {2} + + + Page {0} of {1} + + + {0} / page + + + Rows per page + + + First page + + + Previous page + + + Next page + + + Last page + + + - End of results - + + + Toggle details + + + Toggle children + + + Drag to reorder, or focus and use the arrow keys to move this row + + + Reorder row. Press Arrow Up or Arrow Down to move this row. + + + Invalid value for {0}. + + + Sorted by {0}, ascending + + + Sorted by {0}, descending + + + Sorting by {0} removed + + + Filter applied on {0} + + + Filter on {0} cleared + + + Page {0} of {1} + + + Row deleted diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.sv.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.sv.resx index 57d8f7b4251..060e958999c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.sv.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.sv.resx @@ -177,6 +177,157 @@ Kan inte ansluta till servern. + + + Inga poster att visa. + + + Läser in… + + + Åtgärder + + + + Lägg till + + + Rensa filter + + + Exportera CSV + + + Exportera Excel + + + Kolumner + + + Redigera + + + Ta bort + + + Spara + + + Avbryt + + + Markera alla + + + Markera rad + + + Markera rad: {0} + + + Filtrera… + + + Filtrera efter {0} + + + Alla + + + Filteroperator för {0} + + + Innehåller + + + Innehåller inte + + + Börjar med + + + Slutar med + + + Sant + + + Falskt + + + Gruppera efter {0} + + + Ta bort gruppering efter {0} + + + Expandera grupp {0}: {1} + + + Dölj grupp {0}: {1} + + + {0}–{1} av {2} + + + Sida {0} av {1} + + + {0} / sida + + + Rader per sida + + + Första sidan + + + Föregående sida + + + Nästa sida + + + Sista sidan + + + - Slut på resultaten - + + + Visa/dölj detaljer + + + Visa/dölj underordnade + + + Dra för att ändra ordning, eller fokusera och använd piltangenterna för att flytta raden + + + Ändra ordning på raden. Tryck på uppåt- eller nedåtpil för att flytta raden. + + + Ogiltigt värde för {0}. + + + Sorterat efter {0}, stigande + + + Sorterat efter {0}, fallande + + + Sortering efter {0} borttagen + + + Filter tillämpat på {0} + + + Filter på {0} rensat + + + Sida {0} av {1} + + + Raden har tagits bort + Aktiv diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.zh.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.zh.resx index 421868dd9a4..191fc449dfc 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.zh.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.zh.resx @@ -177,6 +177,157 @@ 无法连接到服务器。 + + + 没有可显示的记录。 + + + 加载中… + + + 操作 + + + + 添加 + + + 清除筛选 + + + 导出 CSV + + + 导出 Excel + + + + + + 编辑 + + + 删除 + + + 保存 + + + 取消 + + + 全选 + + + 选择行 + + + 选择行:{0} + + + 筛选… + + + 按 {0} 筛选 + + + 全部 + + + {0} 的筛选运算符 + + + 包含 + + + 不包含 + + + 开头为 + + + 结尾为 + + + + + + + + + 按 {0} 分组 + + + 取消按 {0} 分组 + + + 展开分组 {0}:{1} + + + 折叠分组 {0}:{1} + + + {0}–{1},共 {2} + + + 第 {0} 页,共 {1} 页 + + + {0} 条/页 + + + 每页行数 + + + 首页 + + + 上一页 + + + 下一页 + + + 末页 + + + - 结果结束 - + + + 切换详情 + + + 切换子项 + + + 拖动以重新排序,或聚焦后使用方向键移动该行 + + + 重新排序行。按向上或向下方向键移动该行。 + + + {0} 的值无效。 + + + 按 {0} 升序排序 + + + 按 {0} 降序排序 + + + 已移除按 {0} 的排序 + + + 已对 {0} 应用筛选 + + + 已清除对 {0} 的筛选 + + + 第 {0} 页,共 {1} 页 + + + 行已删除 + 活动 diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/TemplateConfig/TemplateConfigurationTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/TemplateConfig/TemplateConfigurationTests.cs index 4d69f20eb53..f889006fece 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/TemplateConfig/TemplateConfigurationTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Features/TemplateConfig/TemplateConfigurationTests.cs @@ -39,7 +39,10 @@ public class TemplateConfigurationTests /// private const string TemplateOnlySymbol = "IsInsideProjectTemplate"; - private static readonly string[] skippedDirectories = ["bin", "obj", ".git", ".vs", "node_modules", ".playwright-mcp", "App_Data"]; + // Build and test output, all of it gitignored. `TestResults` matters as much as `bin`: Playwright writes video + // recordings there, and a .webm whose bytes happen to contain a directive-opening sequence is reported as a + // template defect on any machine that has run the UI tests. + private static readonly string[] skippedDirectories = ["bin", "obj", ".git", ".vs", "node_modules", ".playwright-mcp", "App_Data", "TestResults"]; /// /// Template conditionals are always written with a parenthesized condition - #if (aspire == true) - in @@ -178,6 +181,129 @@ public void EveryConcretePathInATemplateRule_Should_StillExist() $"is dead weight:{Environment.NewLine}{string.Join(Environment.NewLine, missing)}"); } + /// + /// True when the number at is the argument of a port flag on a documented command line - + /// -p PORT, --port PORT, --port=PORT. Those really are the app's port and are SUPPOSED to be + /// rewritten per generation; a calendar year or a quantity in the same sentence is not, and neither is preceded by + /// a port flag. Kept as a whitelist of flag spellings rather than "any digits after a space", which would exempt + /// exactly the prose the check exists for. + /// + private static bool IsPortFlagArgument(string line, int index) => portFlag.IsMatch(line[..index]); + + /// + /// The flag has to be a command-line token of its own, anchored to the start of the line or preceded by + /// whitespace - EndsWith("-p") would also accept any word ending in those two characters. + /// + private static readonly Regex portFlag = new(@"(?:^|[ \t])(?:-p|--port|-Port)[ \t]*=?[ \t]*$", RegexOptions.Compiled); + + /// + /// Test files that this review's own suite adds live under src/Tests/Features/** and are gated on + /// advancedTests (plus whatever feature symbol they need), because a generated project should not inherit + /// them and the packages several of them use are gated on the same symbol. Forget the exclusion entry and the + /// DEFAULT generation stops compiling - the file ships while the base class, fake or helper it derives from does + /// not. + /// + /// That has happened: AiChatPanelDictationUITests.cs shipped in no exclude list while its base class + /// AiChatPanelTestBase.cs and the TestChatClient it uses were both removed at + /// advancedTests != true || signalR != true, so dotnet new bit-bp with no arguments produced a test + /// project with two CS0246s. Nothing caught it: inside the template every conditional is a comment so the local + /// build is green, and CI never generates the one combination that breaks - its build-only job passes + /// --signalR false without --advancedTests, and both of its test jobs pass --advancedTests. + /// + /// + /// The allow-list below is the set that is MEANT to ship in every generated project - the sample tests a + /// customer is expected to read and extend. Adding to it is a deliberate act; forgetting an exclusion is not. + /// + /// + /// Scope. This proves the DEFAULT configuration only - the one CI never generates, and the one the + /// failure above shipped in. It does not prove that a file gated on advancedTests alone is also gated on + /// every symbol its dependencies need, so --advancedTests true --signalR false could still pair a test + /// with a missing base class. Closing that needs each file's dependencies resolved against each rule's + /// condition; the generation matrix in CI is the cheaper place to catch it. + /// + /// + [TestMethod] + public void EveryTestFile_Should_BeExcludedFromGeneratedProjects_UnlessItIsADeliberateSample() + { + string[] shippedToEveryGeneratedProject = + [ + "src/Tests/Features/Identity/IntegrationTests.cs", + "src/Tests/Features/Identity/UITests.cs", + "src/Tests/Features/Identity/BunitUITests.cs", + "src/Tests/Features/Identity/TestData.cs" + ]; + + var (templateRoot, template) = LoadTemplateJson(); + + HashSet excluded = new(StringComparer.OrdinalIgnoreCase); + + foreach (var source in template.RootElement.GetProperty("sources").EnumerateArray()) + { + if (source.TryGetProperty("modifiers", out var modifiers) is false) + continue; + + foreach (var modifier in modifiers.EnumerateArray()) + { + if (modifier.TryGetProperty("exclude", out var rule) is false || rule.ValueKind is not JsonValueKind.Array) + continue; + + // Only rules that actually FIRE in the default configuration count. A rule keeps its file whenever + // its condition is false, so "named in some rule" is not the same as "removed by default": an + // exclusion conditioned solely on `(signalR != true)` leaves the file in place as soon as signalR is + // on. Every rule in this list is an OR-chain that starts with `advancedTests != true`, and + // advancedTests defaults to false, so requiring that clause is exactly "this rule fires by default" + // - checked as text rather than by evaluating the expression, which the engine owns. + var condition = modifier.TryGetProperty("condition", out var conditionElement) ? conditionElement.GetString() : null; + + if (condition is null || condition.Contains("advancedTests != true", StringComparison.Ordinal) is false) + continue; + + foreach (var pathElement in rule.EnumerateArray()) + { + var path = pathElement.GetString(); + + if (string.IsNullOrWhiteSpace(path)) + continue; + + excluded.Add(path.Replace('\\', '/').TrimEnd('/')); + } + } + } + + var testsRoot = Path.Combine(templateRoot, "src", "Tests", "Features"); + List unguarded = []; + var filesChecked = 0; + + foreach (var file in EnumerateTemplateFiles(testsRoot).Where(file => Path.GetExtension(file) is ".cs")) + { + var relativePath = Path.GetRelativePath(templateRoot, file).Replace('\\', '/'); + + if (shippedToEveryGeneratedProject.Contains(relativePath, StringComparer.OrdinalIgnoreCase)) + continue; + + filesChecked++; + + // Either the file itself is named, or a `some/directory/**` rule above it removes the whole folder. + var isGated = excluded.Contains(relativePath) || + excluded.Any(entry => entry.EndsWith("/**", StringComparison.Ordinal) && + relativePath.StartsWith(entry[..^2], StringComparison.OrdinalIgnoreCase)); + + if (isGated is false) + { + unguarded.Add(relativePath); + } + } + + // Non-vacuity: the suite has ~90 files under Features. + Assert.IsGreaterThan(50, filesChecked, $"Only {filesChecked} test files were scanned - the scan is not reaching src/Tests/Features."); + + Assert.IsEmpty(unguarded, + "These test files are in no template.json exclude rule that fires in the DEFAULT configuration, so they " + + "ship into a generated project where the base classes and fakes they depend on have been removed. Add " + + "each to the exclude list that matches the symbols it actually needs (advancedTests, plus signalR/" + + $"notification/module/... where relevant):{Environment.NewLine}{string.Join(Environment.NewLine, unguarded)}"); + } + /// /// A "generator": "port" symbol must declare an explicit, non-zero fallback. /// @@ -514,9 +640,11 @@ public void NoNumericReplacesToken_Should_LandInsideALongerNumberOrInMarkdownPro { offenders.Add($"[{token} inside a number] {where}"); } - // Prose: in markdown a port is always written after a `:` (localhost:5030, *:5030). Anything - // else is a sentence that happens to contain the digits. - else if (isMarkdown && before is not ':') + // Prose: in markdown a port is written either after a `:` (localhost:PORT, *:PORT) or as + // the argument of a port flag in a documented command line (`-p PORT`, `--port PORT`). + // Anything else is a sentence that happens to contain the digits - a calendar year, a + // quantity - and the rewrite corrupts it. + else if (isMarkdown && before is not ':' && IsPortFlagArgument(line, index) is false) { offenders.Add($"[{token} in markdown prose] {where}"); } @@ -716,7 +844,7 @@ private static IEnumerable EnumerateTemplateFiles(string templateRoot) .Where(file => Path.GetRelativePath(templateRoot, file) .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) .Any(segment => skippedDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase)) is false) - .Where(file => Path.GetExtension(file) is not (".png" or ".jpg" or ".jpeg" or ".gif" or ".ico" or ".woff" or ".woff2" or ".ttf" or ".dll" or ".pdb" or ".zip" or ".keystore" or ".p12" or ".pfx" or ".webp" or ".mp4")); + .Where(file => Path.GetExtension(file) is not (".png" or ".jpg" or ".jpeg" or ".gif" or ".ico" or ".woff" or ".woff2" or ".ttf" or ".dll" or ".pdb" or ".zip" or ".keystore" or ".p12" or ".pfx" or ".webp" or ".mp4" or ".webm")); } ///