Files
aliasvault/apps/server/AliasVault.Client/Auth/Components/MobileUnlockModal.razor
2025-11-18 22:10:33 +01:00

320 lines
10 KiB
Plaintext

@using AliasVault.Client.Auth.Models
@using AliasVault.Client.Auth.Services
@using AliasVault.Client.Main.Components.Layout
@using AliasVault.Client.Utilities
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.DependencyInjection
@implements IDisposable
@if (IsOpen)
{
<ModalWrapper>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black bg-opacity-80 transition-opacity" @onclick="HandleClose"></div>
<!-- Modal -->
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-5 text-left shadow-xl transition-all w-full max-w-md mx-4">
<!-- Close button -->
<button type="button" class="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none" @onclick="HandleClose">
<span class="sr-only">@SharedLocalizer["Close"]</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Content -->
<div class="mt-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
@Title
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
@Description
</p>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-400 text-sm">
@_errorMessage
</div>
}
@if (!string.IsNullOrEmpty(_qrCodeUrl))
{
<div class="flex flex-col items-center mb-4">
<div id="@_qrElementId" data-url="@_qrCodeUrl" class="@(_isLoading ? "hidden" : "") mb-3 p-4 bg-white rounded-lg border-4 border-gray-200 dark:border-gray-600">
<!-- QR code will be rendered here -->
</div>
@if (!_isLoading)
{
<div class="text-gray-700 dark:text-gray-300 text-sm font-medium">
@FormatTime(_timeRemaining)
</div>
}
</div>
}
@if (_isLoading && string.IsNullOrEmpty(_errorMessage))
{
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
}
<button type="button" @onclick="HandleClose" class="mt-4 w-full inline-flex justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500">
@SharedLocalizer["Cancel"]
</button>
</div>
</div>
</ModalWrapper>
}
@code {
private string? _qrCodeUrl;
private MobileLoginErrorCode? _errorCode;
private string? _errorMessage;
private int _timeRemaining = 120; // 2 minutes in seconds
private MobileLoginUtility? _mobileLoginUtility;
private bool _isLoading = true;
private System.Threading.Timer? _countdownTimer;
private string _qrElementId = $"mobile-unlock-qr-{Guid.NewGuid():N}";
[Inject]
private HttpClient Http { get; set; } = default!;
[Inject]
private JsInteropService JsInteropService { get; set; } = default!;
[Inject]
private ILogger<MobileUnlockModal> Logger { get; set; } = default!;
[Inject]
private IStringLocalizerFactory LocalizerFactory { get; set; } = default!;
[Inject]
private IServiceProvider ScopedServices { get; set; } = default!;
private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client");
private IStringLocalizer MobileLoginLocalizer => LocalizerFactory.Create("MobileLogin", "AliasVault.Client");
/// <summary>
/// Whether the modal is open.
/// </summary>
[Parameter]
public bool IsOpen { get; set; }
/// <summary>
/// Callback when the modal is closed.
/// </summary>
[Parameter]
public EventCallback OnClose { get; set; }
/// <summary>
/// Callback when mobile login/unlock succeeds with the result.
/// </summary>
[Parameter]
public EventCallback<MobileLoginResult> OnSuccess { get; set; }
/// <summary>
/// Mode - 'login' or 'unlock'.
/// </summary>
[Parameter]
public string Mode { get; set; } = "login";
/// <summary>
/// Modal title based on mode.
/// </summary>
private string Title => Mode == "unlock"
? LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["UnlockTitle"]
: LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["PageTitle"];
/// <summary>
/// Modal description based on mode.
/// </summary>
private string Description => LocalizerFactory.Create("Pages.Auth.MobileUnlockModal", "AliasVault.Client")["ScanQrCodeDescription"];
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
if (IsOpen && _mobileLoginUtility == null)
{
await InitiateMobileLoginAsync();
}
else if (!IsOpen)
{
Cleanup();
}
}
/// <summary>
/// Initialize mobile login when modal opens.
/// </summary>
private async Task InitiateMobileLoginAsync()
{
try
{
_isLoading = true;
_errorCode = null;
_errorMessage = null;
_qrCodeUrl = null;
_timeRemaining = 120;
StateHasChanged();
// Initialize mobile login utility
var utilityLogger = ScopedServices.GetRequiredService<ILogger<MobileLoginUtility>>();
_mobileLoginUtility = new MobileLoginUtility(Http, JsInteropService, utilityLogger);
// Initiate mobile login and get QR code data
var requestId = await _mobileLoginUtility.InitiateAsync();
// Generate QR code with AliasVault prefix for mobile login
_qrCodeUrl = $"aliasvault://open/mobile-unlock/{requestId}";
// Render QR code while showing loading
StateHasChanged();
await Task.Delay(100); // Give DOM time to render
await JsInteropService.GenerateQrCode(_qrElementId);
// Wait for QR code to be fully rendered before hiding loading
await Task.Delay(300);
_isLoading = false;
StateHasChanged();
// Start countdown timer
StartCountdownTimer();
// Start polling for response
await _mobileLoginUtility.StartPollingAsync(
HandleSuccessAsync,
HandleErrorAsync);
}
catch (MobileLoginException ex)
{
Logger.LogError(ex, "Error initiating mobile login");
_isLoading = false;
_errorCode = ex.ErrorCode;
_errorMessage = GetErrorMessage(ex.ErrorCode);
StateHasChanged();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error initiating mobile login");
_isLoading = false;
_errorCode = MobileLoginErrorCode.Generic;
_errorMessage = GetErrorMessage(MobileLoginErrorCode.Generic);
StateHasChanged();
}
}
/// <summary>
/// Handle successful mobile login/unlock.
/// </summary>
private async Task HandleSuccessAsync(MobileLoginResult result)
{
try
{
// Call parent success callback
await OnSuccess.InvokeAsync(result);
// Close modal after successful processing
await HandleClose();
}
catch (Exception ex)
{
Logger.LogError(ex, "Error handling mobile login success");
_errorMessage = SharedLocalizer["ErrorUnknown"];
StateHasChanged();
}
}
/// <summary>
/// Handle error.
/// </summary>
private void HandleErrorAsync(MobileLoginErrorCode errorCode)
{
_isLoading = false;
_qrCodeUrl = null; // Hide QR code when error occurs
_errorCode = errorCode;
_errorMessage = GetErrorMessage(errorCode);
StateHasChanged();
}
/// <summary>
/// Get translated error message for error code.
/// </summary>
private string GetErrorMessage(MobileLoginErrorCode errorCode)
{
return errorCode switch
{
MobileLoginErrorCode.Timeout => MobileLoginLocalizer["ErrorTimeout"],
MobileLoginErrorCode.Generic => SharedLocalizer["ErrorUnknown"],
_ => SharedLocalizer["ErrorUnknown"]
};
}
/// <summary>
/// Handle modal close.
/// </summary>
private async Task HandleClose()
{
Cleanup();
await OnClose.InvokeAsync();
}
/// <summary>
/// Cleanup resources.
/// </summary>
private void Cleanup()
{
_mobileLoginUtility?.Cleanup();
_mobileLoginUtility?.Dispose();
_mobileLoginUtility = null;
_countdownTimer?.Dispose();
_countdownTimer = null;
_qrCodeUrl = null;
_errorCode = null;
_errorMessage = null;
_timeRemaining = 120;
_isLoading = true;
}
/// <summary>
/// Format time remaining as MM:SS.
/// </summary>
private string FormatTime(int seconds)
{
var mins = seconds / 60;
var secs = seconds % 60;
return $"{mins}:{secs:D2}";
}
/// <summary>
/// Start countdown timer.
/// </summary>
private void StartCountdownTimer()
{
_countdownTimer = new System.Threading.Timer(_ =>
{
if (_timeRemaining > 0)
{
_timeRemaining--;
InvokeAsync(StateHasChanged);
}
else
{
_countdownTimer?.Dispose();
_mobileLoginUtility?.StopPolling();
}
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
/// <inheritdoc />
public void Dispose()
{
Cleanup();
}
}