diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index ddf6a28fc..b60d90267 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -46,6 +46,7 @@ namespace Bit.Core.Abstractions Task PutShareCipherAsync(string id, CipherShareRequest request); Task PutDeleteCipherAsync(string id); Task PutRestoreCipherAsync(string id); + Task HasUnassignedCiphersAsync(); Task RefreshIdentityTokenAsync(); Task PreValidateSsoAsync(string identifier); Task SendAsync(HttpMethod method, string path, diff --git a/src/Core/Abstractions/ICipherService.cs b/src/Core/Abstractions/ICipherService.cs index b344bc101..91b93e9ce 100644 --- a/src/Core/Abstractions/ICipherService.cs +++ b/src/Core/Abstractions/ICipherService.cs @@ -37,5 +37,6 @@ namespace Bit.Core.Abstractions Task DownloadAndDecryptAttachmentAsync(string cipherId, AttachmentView attachment, string organizationId); Task SoftDeleteWithServerAsync(string id); Task RestoreWithServerAsync(string id); + Task VerifyOrganizationHasUnassignedItemsAsync(); } } diff --git a/src/Core/Abstractions/IStateService.cs b/src/Core/Abstractions/IStateService.cs index 64ed18855..2d8391cfa 100644 --- a/src/Core/Abstractions/IStateService.cs +++ b/src/Core/Abstractions/IStateService.cs @@ -186,6 +186,8 @@ namespace Bit.Core.Abstractions Task GetActiveUserRegionAsync(); Task GetPreAuthRegionAsync(); Task SetPreAuthRegionAsync(BwRegion value); + Task GetShouldCheckOrganizationUnassignedItemsAsync(string userId = null); + Task SetShouldCheckOrganizationUnassignedItemsAsync(bool shouldCheck, string userId = null); [Obsolete("Use GetPinKeyEncryptedUserKeyAsync instead, left for migration purposes")] Task GetPinProtectedAsync(string userId = null); [Obsolete("Use SetPinKeyEncryptedUserKeyAsync instead, left for migration purposes")] diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index e11716326..a6ca528e4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -46,6 +46,7 @@ namespace Bit.Core public const string PreLoginEmailKey = "preLoginEmailKey"; public const string ConfigsKey = "configsKey"; public const string DisplayEuEnvironmentFlag = "display-eu-environment"; + public const string UnassignedItemsBannerFlag = "unassigned-items-banner"; public const string RegionEnvironment = "regionEnvironment"; public const string DuoCallback = "bitwarden://duo-callback"; @@ -136,6 +137,7 @@ namespace Bit.Core public static string ShouldConnectToWatchKey(string userId) => $"shouldConnectToWatch_{userId}"; public static string ScreenCaptureAllowedKey(string userId) => $"screenCaptureAllowed_{userId}"; public static string PendingAdminAuthRequest(string userId) => $"pendingAdminAuthRequest_{userId}"; + public static string ShouldCheckOrganizationUnassignedItemsKey(string userId) => $"shouldCheckOrganizationUnassignedItems_{userId}"; [Obsolete] public static string KeyKey(string userId) => $"key_{userId}"; [Obsolete] diff --git a/src/Core/Models/AppOptions.cs b/src/Core/Models/AppOptions.cs index 58fe79d49..4d5939e51 100644 --- a/src/Core/Models/AppOptions.cs +++ b/src/Core/Models/AppOptions.cs @@ -25,6 +25,7 @@ namespace Bit.App.Models public bool CopyInsteadOfShareAfterSaving { get; set; } public bool HideAccountSwitcher { get; set; } public OtpData? OtpData { get; set; } + public bool HasJustLoggedInOrUnlocked { get; set; } public void SetAllFrom(AppOptions o) { diff --git a/src/Core/Pages/Accounts/LockPage.xaml.cs b/src/Core/Pages/Accounts/LockPage.xaml.cs index bd3be1858..77d499d8a 100644 --- a/src/Core/Pages/Accounts/LockPage.xaml.cs +++ b/src/Core/Pages/Accounts/LockPage.xaml.cs @@ -233,6 +233,7 @@ namespace Bit.App.Pages } var previousPage = await AppHelpers.ClearPreviousPage(); + _appOptions.HasJustLoggedInOrUnlocked = true; App.MainPage = new TabsPage(_appOptions, previousPage); } } diff --git a/src/Core/Pages/Accounts/LoginApproveDevicePage.xaml.cs b/src/Core/Pages/Accounts/LoginApproveDevicePage.xaml.cs index 65d212b5a..8b46fc9e3 100644 --- a/src/Core/Pages/Accounts/LoginApproveDevicePage.xaml.cs +++ b/src/Core/Pages/Accounts/LoginApproveDevicePage.xaml.cs @@ -35,6 +35,8 @@ namespace Bit.App.Pages { return; } + + _appOptions.HasJustLoggedInOrUnlocked = true; var previousPage = await AppHelpers.ClearPreviousPage(); App.MainPage = new TabsPage(_appOptions, previousPage); } diff --git a/src/Core/Pages/Accounts/LoginPage.xaml.cs b/src/Core/Pages/Accounts/LoginPage.xaml.cs index 7139e57b1..434a06273 100644 --- a/src/Core/Pages/Accounts/LoginPage.xaml.cs +++ b/src/Core/Pages/Accounts/LoginPage.xaml.cs @@ -195,6 +195,8 @@ namespace Bit.App.Pages { return; } + + _appOptions.HasJustLoggedInOrUnlocked = true; var previousPage = await AppHelpers.ClearPreviousPage(); App.MainPage = new TabsPage(_appOptions, previousPage); } diff --git a/src/Core/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs b/src/Core/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs index a88f27a03..f8333f972 100644 --- a/src/Core/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs +++ b/src/Core/Pages/Accounts/LoginPasswordlessRequestPage.xaml.cs @@ -55,6 +55,8 @@ namespace Bit.App.Pages { return; } + + _appOptions.HasJustLoggedInOrUnlocked = true; var previousPage = await AppHelpers.ClearPreviousPage(); App.MainPage = new TabsPage(_appOptions, previousPage); } diff --git a/src/Core/Pages/Accounts/SetPasswordPage.xaml.cs b/src/Core/Pages/Accounts/SetPasswordPage.xaml.cs index 19013dba4..5a305cf6c 100644 --- a/src/Core/Pages/Accounts/SetPasswordPage.xaml.cs +++ b/src/Core/Pages/Accounts/SetPasswordPage.xaml.cs @@ -71,6 +71,8 @@ namespace Bit.App.Pages { return; } + + _appOptions.HasJustLoggedInOrUnlocked = true; var previousPage = await AppHelpers.ClearPreviousPage(); App.MainPage = new TabsPage(_appOptions, previousPage); } diff --git a/src/Core/Pages/Accounts/TwoFactorPage.xaml.cs b/src/Core/Pages/Accounts/TwoFactorPage.xaml.cs index 9322936ce..37828567b 100644 --- a/src/Core/Pages/Accounts/TwoFactorPage.xaml.cs +++ b/src/Core/Pages/Accounts/TwoFactorPage.xaml.cs @@ -206,6 +206,8 @@ namespace Bit.App.Pages { return; } + + _appOptions.HasJustLoggedInOrUnlocked = true; var previousPage = await AppHelpers.ClearPreviousPage(); App.MainPage = new TabsPage(_appOptions, previousPage); } diff --git a/src/Core/Pages/TabsPage.cs b/src/Core/Pages/TabsPage.cs index f37c4ee04..367d51e22 100644 --- a/src/Core/Pages/TabsPage.cs +++ b/src/Core/Pages/TabsPage.cs @@ -33,7 +33,7 @@ namespace Bit.App.Pages _keyConnectorService = ServiceContainer.Resolve("keyConnectorService"); _stateService = ServiceContainer.Resolve(); - _groupingsPage = new NavigationPage(new GroupingsPage(true, previousPage: previousPage)) + _groupingsPage = new NavigationPage(new GroupingsPage(true, previousPage: previousPage, appOptions: appOptions)) { Title = AppResources.MyVault, IconImageSource = "lock.png" diff --git a/src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs b/src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs index 46d65d87c..dea34cf22 100644 --- a/src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs +++ b/src/Core/Pages/Vault/GroupingsPage/GroupingsPage.xaml.cs @@ -1,5 +1,6 @@ using Bit.App.Abstractions; using Bit.App.Controls; +using Bit.App.Models; using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -27,7 +28,7 @@ namespace Bit.App.Pages public GroupingsPage(bool mainPage, CipherType? type = null, string folderId = null, string collectionId = null, string pageTitle = null, string vaultFilterSelection = null, - PreviousPageInfo previousPage = null, bool deleted = false, bool showTotp = false) + PreviousPageInfo previousPage = null, bool deleted = false, bool showTotp = false, AppOptions appOptions = null) { _pageName = string.Concat(nameof(GroupingsPage), "_", DateTime.UtcNow.Ticks); InitializeComponent(); @@ -50,6 +51,7 @@ namespace Bit.App.Pages _vm.CollectionId = collectionId; _vm.Deleted = deleted; _vm.ShowTotp = showTotp; + _vm.AppOptions = appOptions; _previousPage = previousPage; if (pageTitle != null) { @@ -160,6 +162,8 @@ namespace Bit.App.Pages return; } + await _vm.CheckOrganizationUnassignedItemsAsync(); + // Push registration var lastPushRegistration = await _stateService.GetPushLastRegistrationDateAsync(); lastPushRegistration = lastPushRegistration.GetValueOrDefault(DateTime.MinValue); diff --git a/src/Core/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs b/src/Core/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs index a249b0b35..08ca10fd7 100644 --- a/src/Core/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs +++ b/src/Core/Pages/Vault/GroupingsPage/GroupingsPageViewModel.cs @@ -1,6 +1,7 @@ using System.Windows.Input; using Bit.App.Abstractions; using Bit.App.Controls; +using Bit.App.Models; using Bit.App.Utilities; using Bit.Core.Abstractions; using Bit.Core.Enums; @@ -45,6 +46,8 @@ namespace Bit.App.Pages private readonly IPasswordRepromptService _passwordRepromptService; private readonly IOrganizationService _organizationService; private readonly IPolicyService _policyService; + private readonly IConfigService _configService; + private readonly IEnvironmentService _environmentService; private readonly ILogger _logger; public GroupingsPageViewModel() @@ -61,6 +64,8 @@ namespace Bit.App.Pages _passwordRepromptService = ServiceContainer.Resolve("passwordRepromptService"); _organizationService = ServiceContainer.Resolve("organizationService"); _policyService = ServiceContainer.Resolve("policyService"); + _configService = ServiceContainer.Resolve(); + _environmentService = ServiceContainer.Resolve(); _logger = ServiceContainer.Resolve("logger"); Loading = true; @@ -104,6 +109,7 @@ namespace Bit.App.Pages public List Collections { get; set; } public List> NestedCollections { get; set; } + public AppOptions AppOptions { get; internal set; } protected override ICipherService cipherService => _cipherService; protected override IPolicyService policyService => _policyService; protected override IOrganizationService organizationService => _organizationService; @@ -699,5 +705,59 @@ namespace Bit.App.Pages var folders = decFolders.Where(f => _allCiphers.Any(c => c.FolderId == f.Id)).ToList(); return folders.Any() ? folders : null; } + + internal async Task CheckOrganizationUnassignedItemsAsync() + { + try + { + if (AppOptions?.HasJustLoggedInOrUnlocked != true) + { + return; + } + + AppOptions.HasJustLoggedInOrUnlocked = false; + + if (!await _configService.GetFeatureFlagBoolAsync(Core.Constants.UnassignedItemsBannerFlag) + || + !await _stateService.GetShouldCheckOrganizationUnassignedItemsAsync()) + { + return; + } + + var waitSyncTask = Task.Run(async () => + { + while (_syncService.SyncInProgress) + { + await Task.Delay(100); + } + }); + await waitSyncTask.WaitAsync(TimeSpan.FromMinutes(5)); + + if (!await _cipherService.VerifyOrganizationHasUnassignedItemsAsync()) + { + return; + } + + var message = _environmentService.SelectedRegion == Core.Enums.Region.SelfHosted + ? AppResources.OrganizationUnassignedItemsMessageSelfHostDescriptionLong + : AppResources.OrganizationUnassignedItemsMessageUSEUDescriptionLong; + + var response = await _deviceActionService.DisplayAlertAsync(AppResources.Notice, + message, + null, + AppResources.RemindMeLater, + AppResources.Ok); + + if (response == AppResources.Ok) + { + await _stateService.SetShouldCheckOrganizationUnassignedItemsAsync(false); + } + } + catch (TimeoutException) { } + catch (Exception ex) + { + _logger.Exception(ex); + } + } } } diff --git a/src/Core/Resources/Localization/AppResources.Designer.cs b/src/Core/Resources/Localization/AppResources.Designer.cs index 38706090e..731604935 100644 --- a/src/Core/Resources/Localization/AppResources.Designer.cs +++ b/src/Core/Resources/Localization/AppResources.Designer.cs @@ -4866,6 +4866,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Notice. + /// + public static string Notice { + get { + return ResourceManager.GetString("Notice", resourceCulture); + } + } + /// /// Looks up a localized string similar to This account has two-step login set up, however, none of the configured two-step providers are supported on this device. Please use a supported device and/or add additional providers that are better supported across devices (such as an authenticator app).. /// @@ -5101,6 +5110,24 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible.. + /// + public static string OrganizationUnassignedItemsMessageSelfHostDescriptionLong { + get { + return ResourceManager.GetString("OrganizationUnassignedItemsMessageSelfHostDescriptionLong", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible.. + /// + public static string OrganizationUnassignedItemsMessageUSEUDescriptionLong { + get { + return ResourceManager.GetString("OrganizationUnassignedItemsMessageUSEUDescriptionLong", resourceCulture); + } + } + /// /// Looks up a localized string similar to Organization identifier. /// @@ -5678,6 +5705,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Remind me later. + /// + public static string RemindMeLater { + get { + return ResourceManager.GetString("RemindMeLater", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remove. /// diff --git a/src/Core/Resources/Localization/AppResources.resx b/src/Core/Resources/Localization/AppResources.resx index acef37157..82450ffab 100644 --- a/src/Core/Resources/Localization/AppResources.resx +++ b/src/Core/Resources/Localization/AppResources.resx @@ -2886,4 +2886,16 @@ Do you want to switch to this account? Launch Duo + + Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible. + + + On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible. + + + Remind me later + + + Notice + diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index e1240ff23..44a9f414c 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -334,6 +334,11 @@ namespace Bit.Core.Services return SendAsync(HttpMethod.Put, string.Concat("/ciphers/", id, "/restore"), null, true, true); } + public Task HasUnassignedCiphersAsync() + { + return SendAsync(HttpMethod.Get, "/ciphers/has-unassigned-ciphers", null, true, true); + } + #endregion #region Attachments APIs diff --git a/src/Core/Services/CipherService.cs b/src/Core/Services/CipherService.cs index 357458970..e96395a52 100644 --- a/src/Core/Services/CipherService.cs +++ b/src/Core/Services/CipherService.cs @@ -829,6 +829,24 @@ namespace Bit.Core.Services await ClearCacheAsync(); } + public async Task VerifyOrganizationHasUnassignedItemsAsync() + { + var organizations = await _stateService.GetOrganizationsAsync(); + if (organizations?.Any() != true) + { + return false; + } + + try + { + return await _apiService.HasUnassignedCiphersAsync(); + } + catch (ApiException ex) when (ex.Error?.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + return false; + } + } + // Helpers private async Task> MakeAttachmentKeyAsync(string organizationId, Cipher cipher = null, CipherView cipherView = null) diff --git a/src/Core/Services/StateService.cs b/src/Core/Services/StateService.cs index e8c997f18..c8a229dc0 100644 --- a/src/Core/Services/StateService.cs +++ b/src/Core/Services/StateService.cs @@ -1384,6 +1384,16 @@ namespace Bit.Core.Services await _storageMediatorService.SaveAsync(Constants.RegionEnvironment, value); } + public async Task GetShouldCheckOrganizationUnassignedItemsAsync(string userId = null) + { + return await _storageMediatorService.GetAsync(await ComposeKeyAsync(Constants.ShouldCheckOrganizationUnassignedItemsKey, userId)) ?? true; + } + + public async Task SetShouldCheckOrganizationUnassignedItemsAsync(bool shouldCheck, string userId = null) + { + await _storageMediatorService.SaveAsync(await ComposeKeyAsync(Constants.ShouldCheckOrganizationUnassignedItemsKey, userId), shouldCheck); + } + // Helpers [Obsolete("Use IStorageMediatorService instead")] diff --git a/src/Core/Utilities/ServiceContainer.cs b/src/Core/Utilities/ServiceContainer.cs index 7d3e32e3b..ae9e8a8c8 100644 --- a/src/Core/Utilities/ServiceContainer.cs +++ b/src/Core/Utilities/ServiceContainer.cs @@ -46,6 +46,7 @@ namespace Bit.Core.Utilities var settingsService = new SettingsService(stateService); var fileUploadService = new FileUploadService(apiService); var configService = new ConfigService(apiService, stateService, logger); + var environmentService = new EnvironmentService(apiService, stateService, conditionedRunner); var cipherService = new CipherService(cryptoService, stateService, settingsService, apiService, fileUploadService, storageService, i18nService, () => searchService, configService, clearCipherCacheKey, allClearCipherCacheKeys); @@ -87,7 +88,6 @@ namespace Bit.Core.Utilities keyConnectorService, passwordGenerationService, policyService, deviceTrustCryptoService, passwordResetEnrollmentService); var exportService = new ExportService(folderService, cipherService, cryptoService); var auditService = new AuditService(cryptoFunctionService, apiService); - var environmentService = new EnvironmentService(apiService, stateService, conditionedRunner); var eventService = new EventService(apiService, stateService, organizationService, cipherService); var usernameGenerationService = new UsernameGenerationService(cryptoService, apiService, stateService);