diff --git a/src/App/App.xaml.cs b/src/App/App.xaml.cs index 059159eb7..aa839370b 100644 --- a/src/App/App.xaml.cs +++ b/src/App/App.xaml.cs @@ -38,6 +38,7 @@ namespace Bit.App private readonly IFileService _fileService; private readonly IAccountsManager _accountsManager; private readonly IPushNotificationService _pushNotificationService; + private readonly IConfigService _configService; private static bool _isResumed; // these variables are static because the app is launching new activities on notification click, creating new instances of App. private static bool _pendingCheckPasswordlessLoginRequests; @@ -61,6 +62,7 @@ namespace Bit.App _fileService = ServiceContainer.Resolve(); _accountsManager = ServiceContainer.Resolve("accountsManager"); _pushNotificationService = ServiceContainer.Resolve(); + _configService = ServiceContainer.Resolve(); _accountsManager.Init(() => Options, this); @@ -169,6 +171,10 @@ namespace Bit.App new NavigationPage(new UpdateTempPasswordPage())); }); } + else if (message.Command == "syncCompleted") + { + await _configService.GetAsync(true); + } else if (message.Command == Constants.PasswordlessLoginRequestKey || message.Command == "unlocked" || message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED) @@ -293,6 +299,8 @@ namespace Bit.App // Reset delay on every start _vaultTimeoutService.DelayLockAndLogoutMs = null; } + + await _configService.GetAsync(); _messagingService.Send("startEventTimer"); } diff --git a/src/App/Pages/Accounts/EnvironmentPageViewModel.cs b/src/App/Pages/Accounts/EnvironmentPageViewModel.cs index 90fc8e698..abac94084 100644 --- a/src/App/Pages/Accounts/EnvironmentPageViewModel.cs +++ b/src/App/Pages/Accounts/EnvironmentPageViewModel.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using System.Windows.Input; using Bit.App.Resources; using Bit.Core.Abstractions; +using Bit.Core.Models.Data; using Bit.Core.Utilities; using Xamarin.CommunityToolkit.ObjectModel; @@ -18,7 +19,8 @@ namespace Bit.App.Pages _environmentService = ServiceContainer.Resolve("environmentService"); PageTitle = AppResources.Settings; - BaseUrl = _environmentService.BaseUrl; + BaseUrl = _environmentService.BaseUrl == EnvironmentUrlData.DefaultEU.Base || EnvironmentUrlData.DefaultUS.Base == _environmentService.BaseUrl ? + string.Empty : _environmentService.BaseUrl; WebVaultUrl = _environmentService.WebVaultUrl; ApiUrl = _environmentService.ApiUrl; IdentityUrl = _environmentService.IdentityUrl; diff --git a/src/App/Pages/Accounts/HomePage.xaml b/src/App/Pages/Accounts/HomePage.xaml index 0bb2b6ebf..b44a243f9 100644 --- a/src/App/Pages/Accounts/HomePage.xaml +++ b/src/App/Pages/Accounts/HomePage.xaml @@ -25,10 +25,6 @@ AutomationProperties.IsInAccessibleTree="True" AutomationProperties.Name="{u:I18n Account}" /> - @@ -66,7 +62,27 @@ + Margin="0, 6, 0 ,0"> + + + + + diff --git a/src/App/Pages/Accounts/HomePage.xaml.cs b/src/App/Pages/Accounts/HomePage.xaml.cs index cc5fdeccc..c374afe9f 100644 --- a/src/App/Pages/Accounts/HomePage.xaml.cs +++ b/src/App/Pages/Accounts/HomePage.xaml.cs @@ -15,6 +15,8 @@ namespace Bit.App.Pages private readonly AppOptions _appOptions; private IBroadcasterService _broadcasterService; + readonly LazyResolve _logger = new LazyResolve(); + public HomePage(AppOptions appOptions = null) { _broadcasterService = ServiceContainer.Resolve("broadcasterService"); @@ -70,6 +72,14 @@ namespace Bit.App.Pages }); } }); + try + { + await _vm.UpdateEnvironment(); + } + catch (Exception ex) + { + _logger.Value?.Exception(ex); + } } protected override bool OnBackButtonPressed() @@ -128,14 +138,6 @@ namespace Bit.App.Pages await Navigation.PushModalAsync(new NavigationPage(page)); } - private void Environment_Clicked(object sender, EventArgs e) - { - if (DoOnce()) - { - _vm.StartEnvironmentAction(); - } - } - private async Task StartEnvironmentAsync() { await _accountListOverlay.HideAsync(); diff --git a/src/App/Pages/Accounts/HomePageViewModel.cs b/src/App/Pages/Accounts/HomePageViewModel.cs index d8a962141..f0ac6e699 100644 --- a/src/App/Pages/Accounts/HomePageViewModel.cs +++ b/src/App/Pages/Accounts/HomePageViewModel.cs @@ -4,7 +4,10 @@ using Bit.App.Abstractions; using Bit.App.Controls; using Bit.App.Resources; using Bit.App.Utilities; +using Bit.Core; using Bit.Core.Abstractions; +using Bit.Core.Models.Data; +using Bit.Core.Models.Response; using Bit.Core.Services; using Bit.Core.Utilities; using Xamarin.CommunityToolkit.ObjectModel; @@ -17,16 +20,19 @@ namespace Bit.App.Pages { private readonly IStateService _stateService; private readonly IMessagingService _messagingService; + private readonly IPlatformUtilsService _platformUtilsService; + private readonly ILogger _logger; + private readonly IEnvironmentService _environmentService; + private readonly IAccountsManager _accountManager; + private readonly IConfigService _configService; private bool _showCancelButton; private bool _rememberEmail; private string _email; + private string _selectedEnvironmentName; private bool _isEmailEnabled; private bool _canLogin; - private IPlatformUtilsService _platformUtilsService; - private ILogger _logger; - private IEnvironmentService _environmentService; - private IAccountsManager _accountManager; + private bool _displayEuEnvironment; public HomeViewModel() { @@ -36,6 +42,7 @@ namespace Bit.App.Pages _logger = ServiceContainer.Resolve(); _environmentService = ServiceContainer.Resolve(); _accountManager = ServiceContainer.Resolve(); + _configService = ServiceContainer.Resolve(); PageTitle = AppResources.Bitwarden; @@ -49,6 +56,8 @@ namespace Bit.App.Pages onException: _logger.Exception, allowsMultipleExecutions: false); CloseCommand = new AsyncCommand(async () => await Device.InvokeOnMainThreadAsync(CloseAction), onException: _logger.Exception, allowsMultipleExecutions: false); + ShowEnvironmentPickerCommand = new AsyncCommand(ShowEnvironmentPickerAsync, + onException: _logger.Exception, allowsMultipleExecutions: false); InitAsync().FireAndForget(); } @@ -71,6 +80,13 @@ namespace Bit.App.Pages additionalPropertyNames: new[] { nameof(CanContinue) }); } + public string SelectedEnvironmentName + { + get => $"{_selectedEnvironmentName} {BitwardenIcons.AngleDown}"; + set => SetProperty(ref _selectedEnvironmentName, value); + } + + public string RegionText => $"{AppResources.Region}:"; public bool CanContinue => !string.IsNullOrEmpty(Email); public FormattedString CreateAccountText @@ -101,11 +117,13 @@ namespace Bit.App.Pages public AsyncCommand ContinueCommand { get; } public AsyncCommand CloseCommand { get; } public AsyncCommand CreateAccountCommand { get; } + public AsyncCommand ShowEnvironmentPickerCommand { get; } public async Task InitAsync() { Email = await _stateService.GetRememberedEmailAsync(); RememberEmail = !string.IsNullOrEmpty(Email); + _displayEuEnvironment = await _configService.GetFeatureFlagBoolAsync(Constants.DisplayEuEnvironmentFlag, forceRefresh: true); } public async Task ContinueToLoginStepAsync() @@ -144,5 +162,56 @@ namespace Bit.App.Pages await _platformUtilsService.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred, AppResources.Ok); } } + + public async Task ShowEnvironmentPickerAsync() + { + var options = _displayEuEnvironment + ? new string[] { AppResources.US, AppResources.EU, AppResources.SelfHosted } + : new string[] { AppResources.US, AppResources.SelfHosted }; + + await Device.InvokeOnMainThreadAsync(async () => + { + var result = await Page.DisplayActionSheet(AppResources.DataRegion, AppResources.Cancel, null, options); + + if (result is null || result == AppResources.Cancel) + { + return; + } + + if (result == AppResources.SelfHosted) + { + StartEnvironmentAction?.Invoke(); + return; + } + + await _environmentService.SetUrlsAsync(result == AppResources.EU ? EnvironmentUrlData.DefaultEU : EnvironmentUrlData.DefaultUS); + SelectedEnvironmentName = result; + }); + } + + public async Task UpdateEnvironment() + { + var environmentsSaved = await _stateService.GetPreAuthEnvironmentUrlsAsync(); + if (environmentsSaved == null || environmentsSaved.IsEmpty) + { + await _environmentService.SetUrlsAsync(EnvironmentUrlData.DefaultUS); + environmentsSaved = EnvironmentUrlData.DefaultUS; + SelectedEnvironmentName = AppResources.US; + return; + } + + if (environmentsSaved.Base == EnvironmentUrlData.DefaultUS.Base) + { + SelectedEnvironmentName = AppResources.US; + } + else if (environmentsSaved.Base == EnvironmentUrlData.DefaultEU.Base) + { + SelectedEnvironmentName = AppResources.EU; + } + else + { + SelectedEnvironmentName = AppResources.SelfHosted; + } + } } } diff --git a/src/App/Pages/Accounts/LoginPageViewModel.cs b/src/App/Pages/Accounts/LoginPageViewModel.cs index cebdffdb3..1bf9b91c1 100644 --- a/src/App/Pages/Accounts/LoginPageViewModel.cs +++ b/src/App/Pages/Accounts/LoginPageViewModel.cs @@ -41,6 +41,7 @@ namespace Bit.App.Pages private bool _isEmailEnabled; private bool _isKnownDevice; private bool _isExecutingLogin; + private string _environmentHostName; public LoginPageViewModel() { @@ -115,6 +116,16 @@ namespace Bit.App.Pages set => SetProperty(ref _isKnownDevice, value); } + public string EnvironmentDomainName + { + get => _environmentHostName; + set => SetProperty(ref _environmentHostName, value, + additionalPropertyNames: new string[] + { + nameof(LoggingInAsText) + }); + } + public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; } public Command LogInCommand { get; } public Command TogglePasswordCommand { get; } @@ -122,7 +133,7 @@ namespace Bit.App.Pages public ICommand LogInWithDeviceCommand { get; } public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye; public string PasswordVisibilityAccessibilityText => ShowPassword ? AppResources.PasswordIsVisibleTapToHide : AppResources.PasswordIsNotVisibleTapToShow; - public string LoggingInAsText => string.Format(AppResources.LoggingInAsX, Email); + public string LoggingInAsText => string.Format(AppResources.LoggingInAsXOnY, Email, EnvironmentDomainName); public bool IsIosExtension { get; set; } public bool CanRemoveAccount { get; set; } public Action StartTwoFactorAction { get; set; } @@ -151,6 +162,7 @@ namespace Bit.App.Pages Email = await _stateService.GetRememberedEmailAsync(); } CanRemoveAccount = await _stateService.GetActiveUserEmailAsync() != Email; + EnvironmentDomainName = CoreHelpers.GetDomain((await _stateService.GetPreAuthEnvironmentUrlsAsync())?.Base); IsKnownDevice = await _apiService.GetKnownDeviceAsync(Email, await _appIdService.GetAppIdAsync()); } catch (ApiException apiEx) when (apiEx.Error.StatusCode == System.Net.HttpStatusCode.Unauthorized) diff --git a/src/App/Resources/AppResources.Designer.cs b/src/App/Resources/AppResources.Designer.cs index 2245d5f78..3810c9cf9 100644 --- a/src/App/Resources/AppResources.Designer.cs +++ b/src/App/Resources/AppResources.Designer.cs @@ -1750,6 +1750,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Data region. + /// + public static string DataRegion { + get { + return ResourceManager.GetString("DataRegion", resourceCulture); + } + } + /// /// Looks up a localized string similar to Password updated. /// @@ -2326,6 +2335,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to EU. + /// + public static string EU { + get { + return ResourceManager.GetString("EU", resourceCulture); + } + } + /// /// Looks up a localized string similar to Exact. /// @@ -3569,11 +3587,11 @@ namespace Bit.App.Resources { } /// - /// Looks up a localized string similar to Logging in as {0}. + /// Looks up a localized string similar to Logging in as {0} on {1}. /// - public static string LoggingInAsX { + public static string LoggingInAsXOnY { get { - return ResourceManager.GetString("LoggingInAsX", resourceCulture); + return ResourceManager.GetString("LoggingInAsXOnY", resourceCulture); } } @@ -5129,6 +5147,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Region. + /// + public static string Region { + get { + return ResourceManager.GetString("Region", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remember me. /// @@ -5462,6 +5489,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to Self-hosted. + /// + public static string SelfHosted { + get { + return ResourceManager.GetString("SelfHosted", resourceCulture); + } + } + /// /// Looks up a localized string similar to Self-hosted environment. /// @@ -6524,6 +6560,15 @@ namespace Bit.App.Resources { } } + /// + /// Looks up a localized string similar to US. + /// + public static string US { + get { + return ResourceManager.GetString("US", resourceCulture); + } + } + /// /// Looks up a localized string similar to Use another two-step login method. /// diff --git a/src/App/Resources/AppResources.resx b/src/App/Resources/AppResources.resx index 5e9c48694..a94cdb2b4 100644 --- a/src/App/Resources/AppResources.resx +++ b/src/App/Resources/AppResources.resx @@ -2496,8 +2496,8 @@ Do you want to switch to this account? Get master password hint - - Logging in as {0} + + Logging in as {0} on {1} Not you? @@ -2610,6 +2610,21 @@ Do you want to switch to this account? There are no items that match the search + + US + + + EU + + + Self-hosted + + + Data region + + + Region + Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour. diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index 19930ce55..0c2474439 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -92,5 +92,6 @@ namespace Bit.Core.Abstractions Task GetUsernameFromAsync(ForwardedEmailServiceType service, UsernameGeneratorConfig config); Task GetKnownDeviceAsync(string email, string deviceIdentifier); Task GetOrgDomainSsoDetailsAsync(string email); + Task GetConfigsAsync(); } } diff --git a/src/Core/Abstractions/IConfigService.cs b/src/Core/Abstractions/IConfigService.cs new file mode 100644 index 000000000..285288d2f --- /dev/null +++ b/src/Core/Abstractions/IConfigService.cs @@ -0,0 +1,15 @@ +using System; +using System.Threading.Tasks; +using Bit.Core.Models.Response; + +namespace Bit.Core.Abstractions +{ + public interface IConfigService + { + Task GetAsync(bool forceRefresh = false); + Task GetFeatureFlagBoolAsync(string key, bool forceRefresh = false, bool defaultValue = false); + Task GetFeatureFlagStringAsync(string key, bool forceRefresh = false, string defaultValue = null); + Task GetFeatureFlagIntAsync(string key, bool forceRefresh = false, int defaultValue = 0); + } +} + diff --git a/src/Core/Abstractions/IStateService.cs b/src/Core/Abstractions/IStateService.cs index 4062599af..355acd093 100644 --- a/src/Core/Abstractions/IStateService.cs +++ b/src/Core/Abstractions/IStateService.cs @@ -174,5 +174,7 @@ namespace Bit.Core.Abstractions Task SetPreLoginEmailAsync(string value); string GetLocale(); void SetLocale(string locale); + ConfigResponse GetConfigs(); + void SetConfigs(ConfigResponse value); } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 0f4b0915c..73863f580 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -41,6 +41,9 @@ public const string NotificationDataType = "Type"; public const string PasswordlessLoginRequestKey = "passwordlessLoginRequest"; public const string PreLoginEmailKey = "preLoginEmailKey"; + public const string ConfigsKey = "configsKey"; + public const string DisplayEuEnvironmentFlag = "display-eu-environment"; + /// /// This key is used to store the value of "ShouldConnectToWatch" of the last user that had logged in /// which is used to handle Apple Watch state logic diff --git a/src/Core/Models/Data/EnvironmentUrlData.cs b/src/Core/Models/Data/EnvironmentUrlData.cs index 744541e19..ae57f804b 100644 --- a/src/Core/Models/Data/EnvironmentUrlData.cs +++ b/src/Core/Models/Data/EnvironmentUrlData.cs @@ -2,6 +2,9 @@ { public class EnvironmentUrlData { + public static EnvironmentUrlData DefaultUS = new EnvironmentUrlData { Base = "https://vault.bitwarden.com" }; + public static EnvironmentUrlData DefaultEU = new EnvironmentUrlData { Base = "https://vault.bitwarden.eu" }; + public string Base { get; set; } public string Api { get; set; } public string Identity { get; set; } @@ -9,5 +12,13 @@ public string Notifications { get; set; } public string WebVault { get; set; } public string Events { get; set; } + + public bool IsEmpty => string.IsNullOrEmpty(Base) + && string.IsNullOrEmpty(Api) + && string.IsNullOrEmpty(Identity) + && string.IsNullOrEmpty(Icons) + && string.IsNullOrEmpty(Notifications) + && string.IsNullOrEmpty(WebVault) + && string.IsNullOrEmpty(Events); } } diff --git a/src/Core/Models/Response/ConfigResponse.cs b/src/Core/Models/Response/ConfigResponse.cs new file mode 100644 index 000000000..d171a4bad --- /dev/null +++ b/src/Core/Models/Response/ConfigResponse.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace Bit.Core.Models.Response +{ + public class ConfigResponse + { + public string Version { get; set; } + public string GitHash { get; set; } + public ServerConfigResponse Server { get; set; } + public EnvironmentConfigResponse Environment { get; set; } + public IDictionary FeatureStates { get; set; } + public DateTime ExpiresOn { get; set; } + } + + public class ServerConfigResponse + { + public string Name { get; set; } + public string Url { get; set; } + } + + public class EnvironmentConfigResponse + { + public string Vault { get; set; } + public string Api { get; set; } + public string Identity { get; set; } + public string Notifications { get; set; } + public string Sso { get; set; } + } +} + diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index d486ac2c4..436916aff 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -585,6 +585,16 @@ namespace Bit.Core.Services #endregion + #region Configs + + public async Task GetConfigsAsync() + { + var accessToken = await _tokenService.GetTokenAsync(); + return await SendAsync(HttpMethod.Get, "/config/", null, !string.IsNullOrEmpty(accessToken), true); + } + + #endregion + #region Helpers public async Task GetActiveBearerTokenAsync() diff --git a/src/Core/Services/ConfigService.cs b/src/Core/Services/ConfigService.cs new file mode 100644 index 000000000..360fc4b0f --- /dev/null +++ b/src/Core/Services/ConfigService.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Exceptions; +using Bit.Core.Models.Domain; +using Bit.Core.Models.Response; +using Bit.Core.Models.View; + +namespace Bit.Core.Services +{ + public class ConfigService : IConfigService + { + private const int UPDATE_INTERVAL_MINS = 60; + private ConfigResponse _configs; + private readonly IApiService _apiService; + private readonly IStateService _stateService; + private readonly ILogger _logger; + + public ConfigService(IApiService apiService, IStateService stateService, ILogger logger) + { + _apiService = apiService; + _stateService = stateService; + _logger = logger; + } + + public async Task GetAsync(bool forceRefresh = false) + { + try + { + _configs = _stateService.GetConfigs(); + if (forceRefresh || _configs?.ExpiresOn is null || _configs.ExpiresOn <= DateTime.UtcNow) + { + _configs = await _apiService.GetConfigsAsync(); + _configs.ExpiresOn = DateTime.UtcNow.AddMinutes(UPDATE_INTERVAL_MINS); + _stateService.SetConfigs(_configs); + } + } + catch (ApiException ex) when (ex.Error.StatusCode == System.Net.HttpStatusCode.BadGateway) + { + // ignore if there is no internet connection and return local configs + } + catch (Exception ex) + { + _logger.Exception(ex); + } + + return _configs; + } + + public async Task GetFeatureFlagBoolAsync(string key, bool forceRefresh = false, bool defaultValue = false) => await GetFeatureFlagAsync(key, forceRefresh, defaultValue); + + public async Task GetFeatureFlagStringAsync(string key, bool forceRefresh = false, string defaultValue = null) => await GetFeatureFlagAsync(key, forceRefresh, defaultValue); + + public async Task GetFeatureFlagIntAsync(string key, bool forceRefresh = false, int defaultValue = 0) => await GetFeatureFlagAsync(key, forceRefresh, defaultValue); + + private async Task GetFeatureFlagAsync(string key, bool forceRefresh = false, T defaultValue = default) + { + await GetAsync(forceRefresh); + if (_configs == null || _configs.FeatureStates == null) + { + return defaultValue; + } + + if (_configs.FeatureStates.TryGetValue(key, out var val) == true + && + val is T actualValue) + { + return actualValue; + } + + return defaultValue; + } + } +} + diff --git a/src/Core/Services/StateService.cs b/src/Core/Services/StateService.cs index 363c44efb..ae2d62d8e 100644 --- a/src/Core/Services/StateService.cs +++ b/src/Core/Services/StateService.cs @@ -1280,6 +1280,16 @@ namespace Bit.Core.Services await SetValueAsync(Constants.PreLoginEmailKey, value, options); } + public ConfigResponse GetConfigs() + { + return _storageMediatorService.Get(Constants.ConfigsKey); + } + + public void SetConfigs(ConfigResponse value) + { + _storageMediatorService.Save(Constants.ConfigsKey, value); + } + // Helpers [Obsolete("Use IStorageMediatorService instead")] diff --git a/src/Core/Utilities/ServiceContainer.cs b/src/Core/Utilities/ServiceContainer.cs index c3b6bbd65..8cab68547 100644 --- a/src/Core/Utilities/ServiceContainer.cs +++ b/src/Core/Utilities/ServiceContainer.cs @@ -87,6 +87,7 @@ namespace Bit.Core.Utilities var userVerificationService = new UserVerificationService(apiService, platformUtilsService, i18nService, cryptoService); var usernameGenerationService = new UsernameGenerationService(cryptoService, apiService, stateService); + var configService = new ConfigService(apiService, stateService, logger); Register(conditionedRunner); Register("tokenService", tokenService); @@ -112,6 +113,7 @@ namespace Bit.Core.Utilities Register("keyConnectorService", keyConnectorService); Register("userVerificationService", userVerificationService); Register(usernameGenerationService); + Register(configService); } public static void Register(string serviceName, T obj)