[SG-834] Mobile pending login requests management screen (#2281)

* Bootstrap new classes for settings list

* [SG-834] Add new method GetActivePasswordlessLoginRequestsAsync to AuthService

* [SG-834] Add generic handle exception method to BaseViewModel

* [SG-834] Add request verification to settings entry

* [SG-834] Add text resources

* [SG-834] Update view and viewmodel

* [SG-834] Remove unnecessary property assignment

* [SG-834] removed logger resolve
This commit is contained in:
André Bispo 2023-02-01 12:22:17 +00:00 committed by GitHub
parent c3ad5f0580
commit e61ca489ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 395 additions and 5 deletions

View file

@ -1,4 +1,6 @@
using Xamarin.Forms;
using System.Linq;
using Xamarin.CommunityToolkit.Converters;
using Xamarin.Forms;
namespace Bit.App.Controls
{
@ -6,4 +8,13 @@ namespace Bit.App.Controls
{
public string ExtraDataForLogging { get; set; }
}
public class SelectionChangedEventArgsConverter : BaseNullableConverterOneWay<SelectionChangedEventArgs, object>
{
public override object? ConvertFrom(SelectionChangedEventArgs? value)
{
return value?.CurrentSelection.FirstOrDefault();
}
}
}

View file

@ -118,7 +118,7 @@ namespace Bit.App.Pages
}
var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(LoginRequest.Id);
if (loginRequestData.RequestApproved.HasValue && loginRequestData.ResponseDate.HasValue)
if (loginRequestData.IsAnswered)
{
await _platformUtilsService.ShowDialogAsync(AppResources.ThisRequestIsNoLongerValid);
await Page.Navigation.PopModalAsync();

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" ?>
<pages:BaseContentPage
xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:xct="http://xamarin.com/schemas/2020/toolkit"
x:Class="Bit.App.Pages.LoginPasswordlessRequestsListPage"
xmlns:pages="clr-namespace:Bit.App.Pages"
x:DataType="pages:LoginPasswordlessRequestsListViewModel"
xmlns:models="clr-namespace:Bit.Core.Models.Response;assembly=BitwardenCore"
xmlns:core="clr-namespace:Bit.Core;assembly=BitwardenCore"
xmlns:controls="clr-namespace:Bit.App.Controls"
xmlns:u="clr-namespace:Bit.App.Utilities"
Title="{Binding PageTitle}">
<ContentPage.BindingContext>
<pages:LoginPasswordlessRequestsListViewModel />
</ContentPage.BindingContext>
<ContentPage.ToolbarItems>
<ToolbarItem Text="{u:I18n Close}" Clicked="Close_Clicked" Order="Primary" Priority="-1" />
</ContentPage.ToolbarItems>
<ContentPage.Resources>
<ResourceDictionary>
<u:DateTimeConverter x:Key="dateTime" />
<xct:ItemSelectedEventArgsConverter x:Key="ItemSelectedEventArgsConverter" />
<controls:SelectionChangedEventArgsConverter x:Key="SelectionChangedEventArgsConverter" />
<DataTemplate
x:Key="loginRequestTemplate"
x:DataType="models:PasswordlessLoginResponse">
<Grid
Padding="10, 0"
RowSpacing="0"
RowDefinitions="*, Auto, *, 10"
ColumnDefinitions="*, *">
<Label
Text="{u:I18n FingerprintPhrase}"
FontSize="Small"
Padding="0, 10, 0 ,0"
FontAttributes="Bold"/>
<controls:MonoLabel
FormattedText="{Binding RequestFingerprint}"
Grid.Row="1"
Grid.ColumnSpan="2"
FontSize="Small"
Padding="0, 5, 0, 10"
VerticalTextAlignment="Center"
TextColor="{DynamicResource FingerprintPhrase}"/>
<Label
Grid.Row="2"
HorizontalOptions="Start"
HorizontalTextAlignment="Start"
Text="{Binding RequestDeviceType}"
StyleClass="list-header-sub" />
<Label
Grid.Row="2"
Grid.Column="1"
HorizontalOptions="End"
HorizontalTextAlignment="End"
Text="{Binding CreationDate, Converter={StaticResource dateTime}}"
StyleClass="list-header-sub" />
<BoxView
StyleClass="list-section-separator-top, list-section-separator-top-platform"
VerticalOptions="End"
Grid.Row="3"
Grid.ColumnSpan="2"/>
</Grid>
</DataTemplate>
<StackLayout
x:Key="mainLayout"
x:Name="_mainLayout"
Padding="0, 10">
<RefreshView
IsRefreshing="{Binding IsRefreshing}"
Command="{Binding RefreshCommand}"
VerticalOptions="FillAndExpand"
BackgroundColor="{DynamicResource BackgroundColor}">
<controls:ExtendedCollectionView
ItemsSource="{Binding LoginRequests}"
ItemTemplate="{StaticResource loginRequestTemplate}"
SelectionMode="Single"
ExtraDataForLogging="Login requests page" >
<controls:ExtendedCollectionView.Behaviors>
<xct:EventToCommandBehavior
EventName="SelectionChanged"
Command="{Binding AnswerRequestCommand}"
EventArgsConverter="{StaticResource SelectionChangedEventArgsConverter}" />
</controls:ExtendedCollectionView.Behaviors>
</controls:ExtendedCollectionView>
</RefreshView>
<controls:IconLabelButton
VerticalOptions="End"
Margin="10,0"
Icon="{Binding Source={x:Static core:BitwardenIcons.Trash}}"
Label="{u:I18n DeclineAllRequests}"
ButtonCommand="{Binding DeclineAllRequestsCommand}"/>
</StackLayout>
</ResourceDictionary>
</ContentPage.Resources>
<ContentView
x:Name="_mainContent">
</ContentView>
</pages:BaseContentPage>

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Bit.Core.Abstractions;
using Bit.Core.Models.Response;
using Bit.Core.Utilities;
using Xamarin.Forms;
namespace Bit.App.Pages
{
public partial class LoginPasswordlessRequestsListPage : BaseContentPage
{
private LoginPasswordlessRequestsListViewModel _vm;
public LoginPasswordlessRequestsListPage()
{
InitializeComponent();
SetActivityIndicator(_mainContent);
_vm = BindingContext as LoginPasswordlessRequestsListViewModel;
_vm.Page = this;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadOnAppearedAsync(_mainLayout, false, _vm.RefreshAsync, _mainContent);
}
private async void Close_Clicked(object sender, System.EventArgs e)
{
if (DoOnce())
{
await Navigation.PopModalAsync();
}
}
}
}

View file

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Bit.App.Abstractions;
using Bit.App.Resources;
using Bit.Core.Abstractions;
using Bit.Core.Models.Response;
using Bit.Core.Utilities;
using Xamarin.CommunityToolkit.ObjectModel;
using Xamarin.Forms;
namespace Bit.App.Pages
{
public class LoginPasswordlessRequestsListViewModel : BaseViewModel
{
private readonly IAuthService _authService;
private readonly IStateService _stateService;
private readonly IDeviceActionService _deviceActionService;
private readonly IPlatformUtilsService _platformUtilsService;
private bool _isRefreshing;
public LoginPasswordlessRequestsListViewModel()
{
_authService = ServiceContainer.Resolve<IAuthService>();
_stateService = ServiceContainer.Resolve<IStateService>();
_deviceActionService = ServiceContainer.Resolve<IDeviceActionService>();
_platformUtilsService = ServiceContainer.Resolve<IPlatformUtilsService>();
PageTitle = AppResources.PendingLogInRequests;
LoginRequests = new ObservableRangeCollection<PasswordlessLoginResponse>();
AnswerRequestCommand = new AsyncCommand<PasswordlessLoginResponse>(PasswordlessLoginAsync,
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
DeclineAllRequestsCommand = new AsyncCommand(DeclineAllRequestsAsync,
onException: ex => HandleException(ex),
allowsMultipleExecutions: false);
RefreshCommand = new Command(async () => await RefreshAsync());
}
public ICommand RefreshCommand { get; }
public AsyncCommand<PasswordlessLoginResponse> AnswerRequestCommand { get; }
public AsyncCommand DeclineAllRequestsCommand { get; }
public ObservableRangeCollection<PasswordlessLoginResponse> LoginRequests { get; }
public bool IsRefreshing
{
get => _isRefreshing;
set => SetProperty(ref _isRefreshing, value);
}
public async Task RefreshAsync()
{
try
{
IsRefreshing = true;
LoginRequests.ReplaceRange(await _authService.GetActivePasswordlessLoginRequestsAsync());
if (!LoginRequests.Any())
{
Page.Navigation.PopModalAsync().FireAndForget();
}
}
catch (Exception ex)
{
HandleException(ex);
}
finally
{
IsRefreshing = false;
}
}
private async Task PasswordlessLoginAsync(PasswordlessLoginResponse request)
{
if (request.IsExpired)
{
await _platformUtilsService.ShowDialogAsync(AppResources.LoginRequestHasAlreadyExpired);
await Page.Navigation.PopModalAsync();
return;
}
var loginRequestData = await _authService.GetPasswordlessLoginRequestByIdAsync(request.Id);
if (loginRequestData.IsAnswered)
{
await _platformUtilsService.ShowDialogAsync(AppResources.ThisRequestIsNoLongerValid);
return;
}
var page = new LoginPasswordlessPage(new LoginPasswordlessDetails()
{
PubKey = loginRequestData.PublicKey,
Id = loginRequestData.Id,
IpAddress = loginRequestData.RequestIpAddress,
Email = await _stateService.GetEmailAsync(),
FingerprintPhrase = loginRequestData.RequestFingerprint,
RequestDate = loginRequestData.CreationDate,
DeviceType = loginRequestData.RequestDeviceType,
Origin = loginRequestData.Origin
});
await Device.InvokeOnMainThreadAsync(() => Application.Current.MainPage.Navigation.PushModalAsync(new NavigationPage(page)));
}
private async Task DeclineAllRequestsAsync()
{
try
{
if (!await _platformUtilsService.ShowDialogAsync(AppResources.AreYouSureYouWantToDeclineAllPendingLogInRequests, null, AppResources.Yes, AppResources.No))
{
return;
}
await _deviceActionService.ShowLoadingAsync(AppResources.Loading);
var taskList = new List<Task>();
foreach (var request in LoginRequests)
{
taskList.Add(_authService.PasswordlessLoginAsync(request.Id, request.PublicKey, false));
}
await Task.WhenAll(taskList);
await _deviceActionService.HideLoadingAsync();
await RefreshAsync();
_platformUtilsService.ShowToast("info", null, AppResources.RequestsDeclined);
}
catch (Exception ex)
{
HandleException(ex);
RefreshAsync().FireAndForget();
}
}
}
}

View file

@ -9,6 +9,7 @@ using Bit.Core.Abstractions;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Models.Domain;
using Bit.Core.Models.Response;
using Bit.Core.Models.View;
using Bit.Core.Services;
using Bit.Core.Utilities;
@ -35,6 +36,7 @@ namespace Bit.App.Pages
private readonly IClipboardService _clipboardService;
private readonly ILogger _loggerService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IAuthService _authService;
private readonly IWatchDeviceService _watchDeviceService;
private const int CustomVaultTimeoutValue = -100;
@ -49,7 +51,6 @@ namespace Bit.App.Pages
private bool _reportLoggingEnabled;
private bool _approvePasswordlessLoginRequests;
private bool _shouldConnectToWatch;
private List<KeyValuePair<string, int?>> _vaultTimeouts =
new List<KeyValuePair<string, int?>>
{
@ -92,8 +93,8 @@ namespace Bit.App.Pages
_clipboardService = ServiceContainer.Resolve<IClipboardService>("clipboardService");
_loggerService = ServiceContainer.Resolve<ILogger>("logger");
_pushNotificationService = ServiceContainer.Resolve<IPushNotificationService>();
_authService = ServiceContainer.Resolve<IAuthService>();
_watchDeviceService = ServiceContainer.Resolve<IWatchDeviceService>();
GroupedItems = new ObservableRangeCollection<ISettingsPageListItem>();
PageTitle = AppResources.Settings;
@ -144,7 +145,6 @@ namespace Bit.App.Pages
!await _keyConnectorService.GetUsesKeyConnector();
_reportLoggingEnabled = await _loggerService.IsEnabled();
_approvePasswordlessLoginRequests = await _stateService.GetApprovePasswordlessLoginsAsync();
_shouldConnectToWatch = await _stateService.GetShouldConnectToWatchAsync();
BuildList();
@ -563,6 +563,14 @@ namespace Bit.App.Pages
ExecuteAsync = () => TwoStepAsync()
}
};
if (_approvePasswordlessLoginRequests)
{
manageItems.Add(new SettingsPageListItem
{
Name = AppResources.PendingLogInRequests,
ExecuteAsync = () => PendingLoginRequestsAsync()
});
}
if (_supportsBiometric || _biometric)
{
var biometricName = AppResources.Biometrics;
@ -754,6 +762,25 @@ namespace Bit.App.Pages
}
}
private async Task PendingLoginRequestsAsync()
{
try
{
var requests = await _authService.GetActivePasswordlessLoginRequestsAsync();
if (requests == null || !requests.Any())
{
_platformUtilsService.ShowToast("info", null, AppResources.NoPendingRequests);
return;
}
Page.Navigation.PushModalAsync(new NavigationPage(new LoginPasswordlessRequestsListPage())).FireAndForget();
}
catch (Exception ex)
{
HandleException(ex);
}
}
private bool IncludeLinksWithSubscriptionInfo()
{
if (Device.RuntimePlatform == Device.iOS)

View file

@ -562,6 +562,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to decline all pending login requests?.
/// </summary>
public static string AreYouSureYouWantToDeclineAllPendingLogInRequests {
get {
return ResourceManager.GetString("AreYouSureYouWantToDeclineAllPendingLogInRequests", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to turn on screen capture?.
/// </summary>
@ -1759,6 +1768,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Decline all requests.
/// </summary>
public static string DeclineAllRequests {
get {
return ResourceManager.GetString("DeclineAllRequests", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Default.
/// </summary>
@ -4283,6 +4301,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to No pending requests.
/// </summary>
public static string NoPendingRequests {
get {
return ResourceManager.GetString("NoPendingRequests", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Nord.
/// </summary>
@ -4770,6 +4797,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Pending login requests.
/// </summary>
public static string PendingLogInRequests {
get {
return ResourceManager.GetString("PendingLogInRequests", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to An organization policy is affecting your ownership options..
/// </summary>
@ -5122,6 +5158,15 @@ namespace Bit.App.Resources {
}
}
/// <summary>
/// Looks up a localized string similar to Requests declined.
/// </summary>
public static string RequestsDeclined {
get {
return ResourceManager.GetString("RequestsDeclined", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Resend code.
/// </summary>

View file

@ -2523,6 +2523,21 @@ Do you want to switch to this account?</value>
<data name="ThisRequestIsNoLongerValid" xml:space="preserve">
<value>This request is no longer valid</value>
</data>
<data name="PendingLogInRequests" xml:space="preserve">
<value>Pending login requests</value>
</data>
<data name="DeclineAllRequests" xml:space="preserve">
<value>Decline all requests</value>
</data>
<data name="AreYouSureYouWantToDeclineAllPendingLogInRequests" xml:space="preserve">
<value>Are you sure you want to decline all pending login requests?</value>
</data>
<data name="RequestsDeclined" xml:space="preserve">
<value>Requests declined</value>
</data>
<data name="NoPendingRequests" xml:space="preserve">
<value>No pending requests</value>
</data>
<data name="EnableCamerPermissionToUseTheScanner" xml:space="preserve">
<value>Enable camera permission to use the scanner</value>
</data>

View file

@ -30,6 +30,7 @@ namespace Bit.Core.Abstractions
Task<AuthResult> LogInPasswordlessAsync(string email, string accessCode, string authRequestId, byte[] decryptionKey, string userKeyCiphered, string localHashedPasswordCiphered);
Task<List<PasswordlessLoginResponse>> GetPasswordlessLoginRequestsAsync();
Task<List<PasswordlessLoginResponse>> GetActivePasswordlessLoginRequestsAsync();
Task<PasswordlessLoginResponse> GetPasswordlessLoginRequestByIdAsync(string id);
Task<PasswordlessLoginResponse> GetPasswordlessLoginResponseAsync(string id, string accessCode);
Task<PasswordlessLoginResponse> PasswordlessLoginAsync(string id, string pubKey, bool requestApproved);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
@ -494,6 +495,12 @@ namespace Bit.Core.Services
return await _apiService.GetAuthRequestAsync();
}
public async Task<List<PasswordlessLoginResponse>> GetActivePasswordlessLoginRequestsAsync()
{
var requests = await GetPasswordlessLoginRequestsAsync();
return requests.Where(r => !r.IsAnswered && !r.IsExpired).OrderByDescending(r => r.CreationDate).ToList();
}
public async Task<PasswordlessLoginResponse> GetPasswordlessLoginRequestByIdAsync(string id)
{
return await _apiService.GetAuthRequestAsync(id);