From 45999329e7a127454ff73906643c7500765c7a6b Mon Sep 17 00:00:00 2001 From: MeysamMoghaddam <65253484+MeysamMoghaddam@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:40:43 +0330 Subject: [PATCH] u --- src/FrontOffice.Main/App.razor | 3 +- src/FrontOffice.Main/App.razor.cs | 50 +- src/FrontOffice.Main/Shared/AuthDialog.razor | 131 ++++++ .../Shared/AuthDialog.razor.cs | 432 ++++++++++++++++++ src/FrontOffice.Main/Shared/MainLayout.razor | 46 +- .../Shared/MainLayout.razor.cs | 33 ++ 6 files changed, 629 insertions(+), 66 deletions(-) create mode 100644 src/FrontOffice.Main/Shared/AuthDialog.razor create mode 100644 src/FrontOffice.Main/Shared/AuthDialog.razor.cs diff --git a/src/FrontOffice.Main/App.razor b/src/FrontOffice.Main/App.razor index 936967d..7828539 100644 --- a/src/FrontOffice.Main/App.razor +++ b/src/FrontOffice.Main/App.razor @@ -1,7 +1,6 @@ @using FrontOffice.Main.Utilities - + diff --git a/src/FrontOffice.Main/App.razor.cs b/src/FrontOffice.Main/App.razor.cs index 911c974..af2e0a1 100644 --- a/src/FrontOffice.Main/App.razor.cs +++ b/src/FrontOffice.Main/App.razor.cs @@ -1,54 +1,6 @@ -using Blazored.LocalStorage; -using FrontOffice.Main.Utilities; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Routing; - -namespace FrontOffice.Main; +namespace FrontOffice.Main; public partial class App { - private const string TokenStorageKey = "auth:token"; - - [Inject] private ILocalStorageService LocalStorage { get; set; } = default!; - - private async Task HandleNavigationAsync(NavigationContext context) - { - var normalizedPath = NormalizePath(context.Path); - if (IsAuthPath(normalizedPath)) - return; - - var token = await LocalStorage.GetItemAsync(TokenStorageKey); - if (!string.IsNullOrWhiteSpace(token)) - return; - - var redirect = string.IsNullOrEmpty(normalizedPath) || normalizedPath == "/" - ? string.Empty - : $"?redirect={Uri.EscapeDataString(normalizedPath)}"; - - Navigation.NavigateTo(RouteConstants.Auth.Phone + redirect, forceLoad: true); - } - - private static bool IsAuthPath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return false; - - if (Uri.TryCreate(path, UriKind.Absolute, out var absolute)) - path = absolute.PathAndQuery; - - path = path.TrimStart('/'); - return path.StartsWith("auth", StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return string.Empty; - - if (Uri.TryCreate(path, UriKind.Absolute, out var absolute)) - path = absolute.PathAndQuery; - - return path.StartsWith('/') ? path : "/" + path; - } } diff --git a/src/FrontOffice.Main/Shared/AuthDialog.razor b/src/FrontOffice.Main/Shared/AuthDialog.razor new file mode 100644 index 0000000..142798b --- /dev/null +++ b/src/FrontOffice.Main/Shared/AuthDialog.razor @@ -0,0 +1,131 @@ + + + @GetDialogTitle() + + + @if (_currentStep == AuthStep.Phone) + { + + لطفاً شماره موبایل خود را وارد کنید تا رمز پویا ارسال شود. + + + + + + + @if (!string.IsNullOrWhiteSpace(_errorMessage)) + { + @_errorMessage + } + + } + else if (_currentStep == AuthStep.Verify) + { + + رمز پویا شش رقمی ارسال ‌شده به @_phoneNumber را وارد کنید. + + + + + @* + تلاش باقی‌مانده: @_attemptsLeft از @MaxVerificationAttempts + *@ + + @if (!string.IsNullOrWhiteSpace(_errorMessage)) + { + + @(_errorMessage) + + } + + @if (!string.IsNullOrWhiteSpace(_infoMessage)) + { + + @(_infoMessage) + + } + + + + تغییر شماره + + + + + + @if (_resendRemaining > 0) + { + + امکان ارسال مجدد تا @_resendRemaining ثانیه دیگر + + } + else + { + + ارسال مجدد رمز پویا + + } + + } + + + لغو + @if (_currentStep == AuthStep.Phone) + { + + ارسال رمز پویا + + } + else if (_currentStep == AuthStep.Verify) + { + + تأیید و ورود + + } + + diff --git a/src/FrontOffice.Main/Shared/AuthDialog.razor.cs b/src/FrontOffice.Main/Shared/AuthDialog.razor.cs new file mode 100644 index 0000000..505547f --- /dev/null +++ b/src/FrontOffice.Main/Shared/AuthDialog.razor.cs @@ -0,0 +1,432 @@ +using Blazored.LocalStorage; +using FrontOffice.BFF.User.Protobuf.Protos.User; +using FrontOffice.BFF.User.Protobuf.Validator; +using FrontOffice.Main.Utilities; +using Grpc.Core; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using MudBlazor; + +namespace FrontOffice.Main.Shared; + +public partial class AuthDialog : IDisposable +{ + private enum AuthStep { Phone, Verify } + private const int DefaultResendCooldown = 120; + public const int MaxVerificationAttempts = 5; + private const string PhoneStorageKey = "auth:phone-number"; + private const string RedirectStorageKey = "auth:redirect"; + private const string TokenStorageKey = "auth:token"; + private const string OtpPurpose = "Login"; + + private AuthStep _currentStep = AuthStep.Phone; + + private CreateNewOtpTokenRequestValidator _phoneRequestValidator = new(); + private CreateNewOtpTokenRequest _phoneRequest = new(); + private MudForm? _phoneForm; + + private VerifyOtpTokenRequestValidator _verifyRequestValidator = new(); + private VerifyOtpTokenRequest _verifyRequest = new(); + private MudForm? _verifyForm; + + private bool _isBusy; + private string? _phoneNumber; + private string? _errorMessage; + private string? _infoMessage; + private Timer? _resendTimer; + private int _resendRemaining; + private int _attemptsLeft = MaxVerificationAttempts; + private CancellationTokenSource? _operationCts; + + [Inject] private ILocalStorageService LocalStorage { get; set; } = default!; + [Inject] private UserContract.UserContractClient UserClient { get; set; } = default!; + + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public EventCallback OnLoginSuccess { get; set; } + + private bool IsVerificationLocked => _attemptsLeft <= 0; + + protected override async Task OnInitializedAsync() + { + _phoneRequest.Purpose = OtpPurpose; + _verifyRequest.Purpose = OtpPurpose; + + var storedPhone = await LocalStorage.GetItemAsync(PhoneStorageKey); + if (!string.IsNullOrWhiteSpace(storedPhone)) + { + _phoneRequest.Mobile = storedPhone; + } + } + + private async Task SendOtpAsync() + { + _errorMessage = null; + if (_phoneForm is null) + return; + + await _phoneForm.Validate(); + if (!_phoneForm.IsValid) + return; + + _isBusy = true; + _operationCts?.Cancel(); + _operationCts?.Dispose(); + _operationCts = new CancellationTokenSource(); + + try + { + var validationResult = _phoneRequestValidator.Validate(_phoneRequest); + if (!validationResult.IsValid) + { + _errorMessage = string.Join(" ", validationResult.Errors.Select(e => e.ErrorMessage).Distinct()); + return; + } + + var metadata = await BuildAuthMetadataAsync(); + CreateNewOtpTokenResponse response; + if (metadata is not null) + { + response = await UserClient.CreateNewOtpTokenAsync(_phoneRequest, metadata, cancellationToken: _operationCts.Token); + } + else + { + response = await UserClient.CreateNewOtpTokenAsync(_phoneRequest, cancellationToken: _operationCts.Token); + } + + if (response?.Success != true) + { + _errorMessage = string.IsNullOrWhiteSpace(response?.Message) + ? "ارسال رمز پویا با خطا مواجه شد. لطفاً دوباره تلاش کنید." + : response!.Message; + return; + } + + await LocalStorage.SetItemAsync(PhoneStorageKey, _phoneRequest.Mobile); + _phoneNumber = _phoneRequest.Mobile; + _currentStep = AuthStep.Verify; + StartResendCountdown(); + } + catch (RpcException rpcEx) + { + _errorMessage = !string.IsNullOrWhiteSpace(rpcEx.Status.Detail) + ? rpcEx.Status.Detail + : "ارسال رمز پویا با خطا مواجه شد. لطفاً دوباره تلاش کنید."; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + finally + { + _isBusy = false; + ClearOperationToken(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task VerifyOtpAsync() + { + _errorMessage = null; + _infoMessage = null; + + if (_verifyForm is null) + return; + + await _verifyForm.Validate(); + if (!_verifyForm.IsValid) + return; + + if (IsVerificationLocked) + { + _errorMessage = "تعداد تلاش‌های مجاز به پایان رسیده است. لطفاً رمز جدید دریافت کنید."; + return; + } + + if (string.IsNullOrWhiteSpace(_phoneNumber)) + { + _errorMessage = "شماره موبایل یافت نشد. لطفاً دوباره تلاش کنید."; + return; + } + + _isBusy = true; + var cancellationToken = PrepareOperationToken(); + + try + { + _verifyRequest.Mobile = _phoneNumber; + var validationResult = _verifyRequestValidator.Validate(_verifyRequest); + if (!validationResult.IsValid) + { + _errorMessage = string.Join(" ", validationResult.Errors.Select(e => e.ErrorMessage).Distinct()); + return; + } + + var metadata = await BuildAuthMetadataAsync(); + VerifyOtpTokenResponse response; + if (metadata is not null) + { + response = await UserClient.VerifyOtpTokenAsync(_verifyRequest, metadata, cancellationToken: cancellationToken); + } + else + { + response = await UserClient.VerifyOtpTokenAsync(_verifyRequest, cancellationToken: cancellationToken); + } + + if (response is null) + { + _errorMessage = "تأیید رمز پویا انجام نشد. لطفاً دوباره تلاش کنید."; + return; + } + + if (response.Success) + { + if (!string.IsNullOrWhiteSpace(response.Token)) + { + await LocalStorage.SetItemAsync(TokenStorageKey, response.Token); + } + else + { + await LocalStorage.RemoveItemAsync(TokenStorageKey); + } + + await LocalStorage.RemoveItemAsync(PhoneStorageKey); + await LocalStorage.RemoveItemAsync(RedirectStorageKey); + + _attemptsLeft = MaxVerificationAttempts; + _verifyRequest.Code = string.Empty; + + await OnLoginSuccess.InvokeAsync(); + MudDialog.Close(); + return; + } + + RegisterFailedAttempt(string.IsNullOrWhiteSpace(response.Message) ? "کد نادرست است." : response.Message); + } + catch (RpcException rpcEx) + { + await HandleVerificationFailureAsync(rpcEx); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + finally + { + _isBusy = false; + ClearOperationToken(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task HandleVerificationFailureAsync(RpcException rpcEx) + { + switch (rpcEx.Status.StatusCode) + { + case StatusCode.PermissionDenied: + case StatusCode.InvalidArgument: + RegisterFailedAttempt(!string.IsNullOrWhiteSpace(rpcEx.Status.Detail) + ? rpcEx.Status.Detail + : "کد نادرست است."); + break; + case StatusCode.Unauthenticated: + await ResetAuthenticationAsync(); + _errorMessage = "نشست کاربری منقضی شده است. لطفاً دوباره وارد شوید."; + _currentStep = AuthStep.Phone; + break; + case StatusCode.DeadlineExceeded: + case StatusCode.NotFound: + _errorMessage = "کد منقضی شده است. لطفاً رمز جدید دریافت کنید."; + break; + default: + _errorMessage = !string.IsNullOrWhiteSpace(rpcEx.Status.Detail) + ? rpcEx.Status.Detail + : "تأیید رمز پویا انجام نشد. لطفاً دوباره تلاش کنید."; + break; + } + } + + private void RegisterFailedAttempt(string baseMessage) + { + _attemptsLeft = Math.Max(0, _attemptsLeft - 1); + + if (_attemptsLeft > 0) + { + _errorMessage = $"{baseMessage} {_attemptsLeft} تلاش باقی مانده است."; + } + else + { + _errorMessage = $"{baseMessage} تلاش‌های مجاز شما به پایان رسیده است. لطفاً رمز جدید دریافت کنید."; + } + } + + private async Task ResendOtpAsync() + { + if (_resendRemaining > 0 || _isBusy || string.IsNullOrWhiteSpace(_phoneNumber)) + { + return; + } + + _errorMessage = null; + _infoMessage = null; + _isBusy = true; + var cancellationToken = PrepareOperationToken(); + + try + { + var request = new CreateNewOtpTokenRequest + { + Mobile = _phoneNumber, + Purpose = OtpPurpose + }; + + var metadata = await BuildAuthMetadataAsync(); + CreateNewOtpTokenResponse response; + if (metadata is not null) + { + response = await UserClient.CreateNewOtpTokenAsync(request, metadata, cancellationToken: cancellationToken); + } + else + { + response = await UserClient.CreateNewOtpTokenAsync(request, cancellationToken: cancellationToken); + } + + if (response?.Success != true) + { + _errorMessage = string.IsNullOrWhiteSpace(response?.Message) + ? "ارسال مجدد رمز پویا با خطا مواجه شد." + : response!.Message; + return; + } + + _infoMessage = string.IsNullOrWhiteSpace(response.Message) + ? "کد جدید ارسال شد." + : response.Message; + + _attemptsLeft = MaxVerificationAttempts; + _verifyRequest.Code = string.Empty; + StartResendCountdown(); + } + catch (RpcException rpcEx) + { + await HandleResendFailureAsync(rpcEx); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + finally + { + _isBusy = false; + ClearOperationToken(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task HandleResendFailureAsync(RpcException rpcEx) + { + switch (rpcEx.Status.StatusCode) + { + case StatusCode.Unauthenticated: + await ResetAuthenticationAsync(); + _errorMessage = "نشست کاربری منقضی شده است. لطفاً دوباره وارد شوید."; + _currentStep = AuthStep.Phone; + break; + default: + _errorMessage = !string.IsNullOrWhiteSpace(rpcEx.Status.Detail) + ? rpcEx.Status.Detail + : "ارسال مجدد رمز پویا با خطا مواجه شد."; + break; + } + } + + private async Task ChangePhoneAsync() + { + await LocalStorage.RemoveItemAsync(PhoneStorageKey); + _currentStep = AuthStep.Phone; + _phoneNumber = null; + _verifyRequest.Code = string.Empty; + _attemptsLeft = MaxVerificationAttempts; + _resendTimer?.Dispose(); + _resendTimer = null; + _resendRemaining = 0; + } + + private void StartResendCountdown(int seconds = DefaultResendCooldown) + { + _resendRemaining = seconds; + _resendTimer?.Dispose(); + _resendTimer = new Timer(_ => + { + var remaining = Interlocked.Add(ref _resendRemaining, -1); + if (remaining <= 0) + { + Interlocked.Exchange(ref _resendRemaining, 0); + _resendTimer?.Dispose(); + _resendTimer = null; + } + + _ = InvokeAsync(StateHasChanged); + }, null, 1000, 1000); + } + + private async Task BuildAuthMetadataAsync() + { + var token = await LocalStorage.GetItemAsync(TokenStorageKey); + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + return new Metadata + { + { "Authorization", $"Bearer {token}" } + }; + } + + private async Task ResetAuthenticationAsync() + { + await LocalStorage.RemoveItemAsync(TokenStorageKey); + await LocalStorage.RemoveItemAsync(PhoneStorageKey); + await LocalStorage.RemoveItemAsync(RedirectStorageKey); + } + + private CancellationToken PrepareOperationToken() + { + _operationCts?.Cancel(); + _operationCts?.Dispose(); + _operationCts = new CancellationTokenSource(); + return _operationCts.Token; + } + + private void ClearOperationToken() + { + _operationCts?.Dispose(); + _operationCts = null; + } + + private void Cancel() + { + MudDialog.Cancel(); + } + + public void Dispose() + { + _operationCts?.Cancel(); + _operationCts?.Dispose(); + _operationCts = null; + + _resendTimer?.Dispose(); + _resendTimer = null; + } + private string GetDialogTitle() => _currentStep == AuthStep.Phone ? "ورود به حساب کاربری" : "تأیید رمز پویا"; +} \ No newline at end of file diff --git a/src/FrontOffice.Main/Shared/MainLayout.razor b/src/FrontOffice.Main/Shared/MainLayout.razor index aea8bd2..768b711 100644 --- a/src/FrontOffice.Main/Shared/MainLayout.razor +++ b/src/FrontOffice.Main/Shared/MainLayout.razor @@ -19,15 +19,19 @@
- - - - فرصت + + + + + + فرصت + +
@@ -35,13 +39,19 @@ قیمت‌ها سوالات متداول ارتباط با ما - پروفایل
- ورود - شروع کنید + @if (_isAuthenticated) + { + پروفایل + } + else + { + ورود + شروع کنید + } قیمت‌ها سوالات متداول ارتباط - پروفایل + - ورود - شروع کنید + @if (_isAuthenticated) + { + پروفایل + } + else + { + ورود + } diff --git a/src/FrontOffice.Main/Shared/MainLayout.razor.cs b/src/FrontOffice.Main/Shared/MainLayout.razor.cs index a245c9c..18c7dec 100644 --- a/src/FrontOffice.Main/Shared/MainLayout.razor.cs +++ b/src/FrontOffice.Main/Shared/MainLayout.razor.cs @@ -1,26 +1,59 @@ +using Blazored.LocalStorage; +using FrontOffice.Main.Utilities; +using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using MudBlazor; namespace FrontOffice.Main.Shared; public partial class MainLayout { + private const string TokenStorageKey = "auth:token"; + private MudThemeProvider _mudThemeProvider; private bool _isDark; private bool _drawerOpen; + private bool _isAuthenticated; private string? _email; + [Inject] private ILocalStorageService LocalStorage { get; set; } = default!; + private void ToggleTheme() => _isDark = !_isDark; private void ToggleDrawer() => _drawerOpen = !_drawerOpen; private async void Back() { await JSRuntime.InvokeVoidAsync("history.back"); } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { await JSRuntime.InvokeVoidAsync("changeNavBgOnBodyScroll", "top", null, 1); + await CheckAuthStatus(); StateHasChanged(); } } + + private async Task CheckAuthStatus() + { + var token = await LocalStorage.GetItemAsync(TokenStorageKey); + _isAuthenticated = !string.IsNullOrWhiteSpace(token); + } + + private async Task OpenAuthDialog() + { + var dialog = await DialogService.ShowAsync("ورود به حساب کاربری"); + var result = await dialog.Result; + + if (!result.Canceled) + { + await CheckAuthStatus(); + StateHasChanged(); + } + } + + private void NavigateToProfile() + { + Navigation.NavigateTo(RouteConstants.Profile.Index); + } } \ No newline at end of file