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 @@ public interface IApiService Task<CipherResponse> PutShareCipherAsync(string id, CipherShareRequest request); Task PutDeleteCipherAsync(string id); Task<CipherResponse> PutRestoreCipherAsync(string id); + Task<bool> HasUnassignedCiphersAsync(); Task RefreshIdentityTokenAsync(); Task<SsoPrevalidateResponse> PreValidateSsoAsync(string identifier); Task<TResponse> SendAsync<TRequest, TResponse>(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 @@ Task<Tuple<List<CipherView>, List<CipherView>, List<CipherView>>> GetAllDecrypte Task<byte[]> DownloadAndDecryptAttachmentAsync(string cipherId, AttachmentView attachment, string organizationId); Task SoftDeleteWithServerAsync(string id); Task RestoreWithServerAsync(string id); + Task<bool> 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 @@ public interface IStateService Task<BwRegion?> GetActiveUserRegionAsync(); Task<BwRegion?> GetPreAuthRegionAsync(); Task SetPreAuthRegionAsync(BwRegion value); + Task<bool> GetShouldCheckOrganizationUnassignedItemsAsync(string userId = null); + Task SetShouldCheckOrganizationUnassignedItemsAsync(bool shouldCheck, string userId = null); [Obsolete("Use GetPinKeyEncryptedUserKeyAsync instead, left for migration purposes")] Task<string> 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 @@ public static class Constants 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 @@ public static string AccountBiometricIntegrityValidKey(string userId, string sys 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 @@ public class AppOptions 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 @@ private async Task UnlockedAsync() } 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 @@ private async Task ContinueToVaultAsync() { 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 @@ private async Task LogInSuccessAsync() { 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 @@ private async Task LogInSuccessAsync() { 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 @@ private async Task SetPasswordSuccessAsync() { 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 @@ private async Task TwoFactorAuthSuccessToMainAsync() { 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 @@ public TabsPage(AppOptions appOptions = null, PreviousPageInfo previousPage = nu _keyConnectorService = ServiceContainer.Resolve<IKeyConnectorService>("keyConnectorService"); _stateService = ServiceContainer.Resolve<IStateService>(); - _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 @@ public partial class GroupingsPage : BaseContentPage 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 @@ public GroupingsPage(bool mainPage, CipherType? type = null, string folderId = n _vm.CollectionId = collectionId; _vm.Deleted = deleted; _vm.ShowTotp = showTotp; + _vm.AppOptions = appOptions; _previousPage = previousPage; if (pageTitle != null) { @@ -160,6 +162,8 @@ await LoadOnAppearedAsync(_mainLayout, false, async () => 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 @@ public class GroupingsPageViewModel : VaultFilterViewModel 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 @@ public GroupingsPageViewModel() _passwordRepromptService = ServiceContainer.Resolve<IPasswordRepromptService>("passwordRepromptService"); _organizationService = ServiceContainer.Resolve<IOrganizationService>("organizationService"); _policyService = ServiceContainer.Resolve<IPolicyService>("policyService"); + _configService = ServiceContainer.Resolve<IConfigService>(); + _environmentService = ServiceContainer.Resolve<IEnvironmentService>(); _logger = ServiceContainer.Resolve<ILogger>("logger"); Loading = true; @@ -104,6 +109,7 @@ public GroupingsPageViewModel() public List<Core.Models.View.CollectionView> Collections { get; set; } public List<TreeNode<Core.Models.View.CollectionView>> 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 @@ private List<FolderView> BuildFolders(List<FolderView> decFolders) 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 @@ public static string NoThanks { } } + /// <summary> + /// Looks up a localized string similar to Notice. + /// </summary> + public static string Notice { + get { + return ResourceManager.GetString("Notice", resourceCulture); + } + } + /// <summary> /// 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).. /// </summary> @@ -5101,6 +5110,24 @@ public static string OrganizationSsoIdentifierRequired { } } + /// <summary> + /// 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.. + /// </summary> + public static string OrganizationUnassignedItemsMessageSelfHostDescriptionLong { + get { + return ResourceManager.GetString("OrganizationUnassignedItemsMessageSelfHostDescriptionLong", resourceCulture); + } + } + + /// <summary> + /// 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.. + /// </summary> + public static string OrganizationUnassignedItemsMessageUSEUDescriptionLong { + get { + return ResourceManager.GetString("OrganizationUnassignedItemsMessageUSEUDescriptionLong", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Organization identifier. /// </summary> @@ -5678,6 +5705,15 @@ public static string RememberThisDevice { } } + /// <summary> + /// Looks up a localized string similar to Remind me later. + /// </summary> + public static string RemindMeLater { + get { + return ResourceManager.GetString("RemindMeLater", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Remove. /// </summary> 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?</value> <data name="LaunchDuo" xml:space="preserve"> <value>Launch Duo</value> </data> + <data name="OrganizationUnassignedItemsMessageUSEUDescriptionLong" xml:space="preserve"> + <value>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.</value> + </data> + <data name="OrganizationUnassignedItemsMessageSelfHostDescriptionLong" xml:space="preserve"> + <value>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.</value> + </data> + <data name="RemindMeLater" xml:space="preserve"> + <value>Remind me later</value> + </data> + <data name="Notice" xml:space="preserve"> + <value>Notice</value> + </data> </root> 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 @@ public Task<CipherResponse> PutRestoreCipherAsync(string id) return SendAsync<object, CipherResponse>(HttpMethod.Put, string.Concat("/ciphers/", id, "/restore"), null, true, true); } + public Task<bool> HasUnassignedCiphersAsync() + { + return SendAsync<object, bool>(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 @@ public async Task RestoreWithServerAsync(string id) await ClearCacheAsync(); } + public async Task<bool> 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<Tuple<SymmetricCryptoKey, EncString, SymmetricCryptoKey>> 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 @@ public async Task SetPreAuthRegionAsync(BwRegion value) await _storageMediatorService.SaveAsync(Constants.RegionEnvironment, value); } + public async Task<bool> GetShouldCheckOrganizationUnassignedItemsAsync(string userId = null) + { + return await _storageMediatorService.GetAsync<bool?>(await ComposeKeyAsync(Constants.ShouldCheckOrganizationUnassignedItemsKey, userId)) ?? true; + } + + public async Task SetShouldCheckOrganizationUnassignedItemsAsync(bool shouldCheck, string userId = null) + { + await _storageMediatorService.SaveAsync<bool?>(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 @@ public static void Init(string customUserAgent = null, string clearCipherCacheKe 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 @@ public static void Init(string customUserAgent = null, string clearCipherCacheKe 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);