From d4b5a1352c66a08ee530d95b97374dd8a8708f6f Mon Sep 17 00:00:00 2001 From: MeysamMoghaddam <65253484+MeysamMoghaddam@users.noreply.github.com> Date: Sun, 28 Sep 2025 03:24:54 +0330 Subject: [PATCH] u --- src/FrontOffice.Main/Pages/Auth/Phone.razor | 54 +++ .../Pages/Auth/Phone.razor.cs | 149 ++++++++ src/FrontOffice.Main/Pages/Auth/Verify.razor | 84 +++++ .../Pages/Auth/Verify.razor.cs | 333 ++++++++++++++++++ 4 files changed, 620 insertions(+) create mode 100644 src/FrontOffice.Main/Pages/Auth/Phone.razor create mode 100644 src/FrontOffice.Main/Pages/Auth/Phone.razor.cs create mode 100644 src/FrontOffice.Main/Pages/Auth/Verify.razor create mode 100644 src/FrontOffice.Main/Pages/Auth/Verify.razor.cs diff --git a/src/FrontOffice.Main/Pages/Auth/Phone.razor b/src/FrontOffice.Main/Pages/Auth/Phone.razor new file mode 100644 index 0000000..cc6b662 --- /dev/null +++ b/src/FrontOffice.Main/Pages/Auth/Phone.razor @@ -0,0 +1,54 @@ +@page "/auth/phone" + +ورود | تأیید شماره موبایل + + + + + ورود به حساب کاربری + لطفاً شماره موبایل خود را وارد کنید تا رمز پویا ارسال شود. + + + + + + + @if (!string.IsNullOrWhiteSpace(_errorMessage)) + { + @_errorMessage + } + + + ارسال رمز پویا + + + + + + با ورود، شرایط استفاده و سیاست حفظ حریم خصوصی را می‌پذیرید. + + + + + + + + diff --git a/src/FrontOffice.Main/Pages/Auth/Phone.razor.cs b/src/FrontOffice.Main/Pages/Auth/Phone.razor.cs new file mode 100644 index 0000000..3934c3d --- /dev/null +++ b/src/FrontOffice.Main/Pages/Auth/Phone.razor.cs @@ -0,0 +1,149 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Blazored.LocalStorage; +using FrontOffice.BFF.User.Protobuf.Protos.User; +using Grpc.Core; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using MudBlazor; + +namespace FrontOffice.Main.Pages.Auth; + +public partial class Phone : IDisposable +{ + private const string PhoneStorageKey = "auth:phone-number"; + private const string RedirectStorageKey = "auth:redirect"; + private const string OtpPurpose = "Login"; + + private readonly PhoneInputModel _model = new(); + private MudForm? _form; + private bool _isBusy; + private string? _errorMessage; + private string? _redirect; + private CancellationTokenSource? _sendCts; + + [Inject] private ILocalStorageService LocalStorage { get; set; } = default!; + [Inject] private UserContract.UserContractClient UserClient { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + if (query.TryGetValue("redirect", out var redirectValues)) + { + _redirect = redirectValues.LastOrDefault(); + } + + var storedPhone = await LocalStorage.GetItemAsync(PhoneStorageKey); + if (!string.IsNullOrWhiteSpace(storedPhone)) + { + _model.PhoneNumber = storedPhone; + } + + if (string.IsNullOrWhiteSpace(_redirect)) + { + var storedRedirect = await LocalStorage.GetItemAsync(RedirectStorageKey); + if (!string.IsNullOrWhiteSpace(storedRedirect)) + { + _redirect = storedRedirect; + } + } + } + + private async Task SendOtpAsync() + { + _errorMessage = null; + if (_form is null) + { + return; + } + + await _form.Validate(); + if (!_form.IsValid) + { + return; + } + + _isBusy = true; + _sendCts?.Cancel(); + _sendCts?.Dispose(); + _sendCts = new CancellationTokenSource(); + + try + { + var request = new CreateNewOtpTokenRequest + { + Mobile = _model.PhoneNumber, + Purpose = OtpPurpose + }; + + var response = await UserClient.CreateNewOtpTokenAsync(request, cancellationToken: _sendCts.Token); + if (response?.Success != true) + { + _errorMessage = string.IsNullOrWhiteSpace(response?.Message) + ? "ارسال رمز پویا با خطا مواجه شد. لطفاً دوباره تلاش کنید." + : response!.Message; + return; + } + + await LocalStorage.SetItemAsync(PhoneStorageKey, _model.PhoneNumber); + if (!string.IsNullOrWhiteSpace(_redirect)) + { + await LocalStorage.SetItemAsync(RedirectStorageKey, _redirect); + } + else + { + await LocalStorage.RemoveItemAsync(RedirectStorageKey); + } + + var target = "/auth/verify?phone=" + Uri.EscapeDataString(_model.PhoneNumber); + if (!string.IsNullOrEmpty(_redirect)) + { + target += "&redirect=" + Uri.EscapeDataString(_redirect); + } + + Navigation.NavigateTo(target, forceLoad: false); + } + catch (RpcException rpcEx) + { + _errorMessage = !string.IsNullOrWhiteSpace(rpcEx.Status.Detail) + ? rpcEx.Status.Detail + : "ارسال رمز پویا با خطا مواجه شد. لطفاً دوباره تلاش کنید."; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + finally + { + _isBusy = false; + _sendCts?.Dispose(); + _sendCts = null; + await InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + _sendCts?.Cancel(); + _sendCts?.Dispose(); + _sendCts = null; + } + + private sealed class PhoneInputModel + { + [Required(ErrorMessage = "وارد کردن شماره موبایل الزامی است.")] + [RegularExpression(@"^09\\d{9}$", ErrorMessage = "شماره موبایل معتبر نیست.")] + public string PhoneNumber { get; set; } = string.Empty; + + [Range(typeof(bool), "true", "true", ErrorMessage = "پذیرش شرایط و قوانین ضروری است.")] + public bool AcceptTerms { get; set; } + } +} + diff --git a/src/FrontOffice.Main/Pages/Auth/Verify.razor b/src/FrontOffice.Main/Pages/Auth/Verify.razor new file mode 100644 index 0000000..aee4973 --- /dev/null +++ b/src/FrontOffice.Main/Pages/Auth/Verify.razor @@ -0,0 +1,84 @@ +@page "/auth/verify" + +تأیید رمز پویا + + + + + تأیید رمز پویا + @if (!string.IsNullOrWhiteSpace(_phoneNumber)) + { + کد ارسال‌شده به @_phoneNumber را وارد کنید. + } + else + { + کد ارسال‌شده را وارد کنید. + } + + + + + + تلاش باقی‌مانده: @_attemptsLeft از @MaxVerificationAttempts + + + @if (!string.IsNullOrWhiteSpace(_errorMessage)) + { + @_errorMessage + } + + @if (!string.IsNullOrWhiteSpace(_infoMessage)) + { + @_infoMessage + } + + + + تأیید و ورود + + + تغییر شماره + + + + + + @if (_resendRemaining > 0) + { + + امکان ارسال مجدد تا @_resendRemaining ثانیه دیگر + + } + else + { + + ارسال مجدد رمز پویا + + } + + + + + + + diff --git a/src/FrontOffice.Main/Pages/Auth/Verify.razor.cs b/src/FrontOffice.Main/Pages/Auth/Verify.razor.cs new file mode 100644 index 0000000..cae2994 --- /dev/null +++ b/src/FrontOffice.Main/Pages/Auth/Verify.razor.cs @@ -0,0 +1,333 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Blazored.LocalStorage; +using FrontOffice.BFF.User.Protobuf.Protos.User; +using FrontOffice.Main.Utilities; +using Grpc.Core; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using MudBlazor; + +namespace FrontOffice.Main.Pages.Auth; + +public partial class Verify : IDisposable +{ + private const int DefaultResendCooldown = 120; + public const int MaxVerificationAttempts = 5; + private const string OtpPurpose = "Login"; + private const string PhoneStorageKey = "auth:phone-number"; + private const string RedirectStorageKey = "auth:redirect"; + private const string TokenStorageKey = "auth:token"; + + private readonly OtpInputModel _model = new(); + private MudForm? _form; + private bool _isBusy; + private string? _phoneNumber; + private string? _redirect; + 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!; + + private bool IsVerificationLocked => _attemptsLeft <= 0; + + protected override async Task OnInitializedAsync() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + if (query.TryGetValue("redirect", out var redirectValues)) + { + _redirect = redirectValues.LastOrDefault(); + } + + if (query.TryGetValue("phone", out var phoneValues)) + { + _phoneNumber = phoneValues.LastOrDefault(); + } + + if (string.IsNullOrWhiteSpace(_phoneNumber)) + { + _phoneNumber = await LocalStorage.GetItemAsync(PhoneStorageKey); + } + + if (string.IsNullOrWhiteSpace(_phoneNumber)) + { + NavigateBackToPhone(); + return; + } + + await LocalStorage.SetItemAsync(PhoneStorageKey, _phoneNumber); + + if (string.IsNullOrWhiteSpace(_redirect)) + { + var storedRedirect = await LocalStorage.GetItemAsync(RedirectStorageKey); + if (!string.IsNullOrWhiteSpace(storedRedirect)) + { + _redirect = storedRedirect; + } + } + + StartResendCountdown(); + } + + private async Task VerifyOtpAsync() + { + _errorMessage = null; + _infoMessage = null; + + if (_form is null) + { + return; + } + + await _form.Validate(); + if (!_form.IsValid) + { + return; + } + + if (IsVerificationLocked) + { + _errorMessage = "???????? ???? ?? ????? ????? ???. ????? ??? ???? ?????? ????."; + return; + } + + if (string.IsNullOrWhiteSpace(_phoneNumber)) + { + NavigateBackToPhone(); + return; + } + + _isBusy = true; + var cancellationToken = PrepareOperationToken(); + + try + { + var request = new VerifyOtpTokenRequest + { + Mobile = _phoneNumber, + Purpose = OtpPurpose, + Code = _model.Code + }; + + var response = await UserClient.VerifyOtpTokenAsync(request, cancellationToken: cancellationToken); + if (response is null) + { + _errorMessage = "????? ??? ???? ????? ???. ????? ?????? ???? ????."; + return; + } + + if (response.Success) + { + if (!string.IsNullOrWhiteSpace(response.Token)) + { + await LocalStorage.SetItemAsync(TokenStorageKey, response.Token); + } + + await LocalStorage.RemoveItemAsync(PhoneStorageKey); + await LocalStorage.RemoveItemAsync(RedirectStorageKey); + + _attemptsLeft = MaxVerificationAttempts; + _model.Code = string.Empty; + + var target = !string.IsNullOrWhiteSpace(_redirect) ? _redirect : RouteConstants.Main.MainPage; + if (!string.IsNullOrWhiteSpace(target)) + { + Navigation.NavigateTo(target, forceLoad: true); + } + return; + } + + RegisterFailedAttempt(string.IsNullOrWhiteSpace(response.Message) ? "?? ?????? ???." : response.Message); + } + catch (RpcException rpcEx) + { + HandleVerificationFailure(rpcEx); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + finally + { + _isBusy = false; + ClearOperationToken(); + await InvokeAsync(StateHasChanged); + } + } + + private void HandleVerificationFailure(RpcException rpcEx) + { + switch (rpcEx.Status.StatusCode) + { + case StatusCode.PermissionDenied: + case StatusCode.InvalidArgument: + case StatusCode.Unauthenticated: + RegisterFailedAttempt(!string.IsNullOrWhiteSpace(rpcEx.Status.Detail) + ? rpcEx.Status.Detail + : "?? ?????? ???."); + 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 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; + _model.Code = string.Empty; + 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 ChangePhoneAsync() + { + await LocalStorage.RemoveItemAsync(PhoneStorageKey); + NavigateBackToPhone(); + } + + private void NavigateBackToPhone() + { + var target = "/auth/phone"; + if (!string.IsNullOrWhiteSpace(_redirect)) + { + target += "?redirect=" + Uri.EscapeDataString(_redirect); + } + + Navigation.NavigateTo(target, forceLoad: false); + } + + 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 CancellationToken PrepareOperationToken() + { + _operationCts?.Cancel(); + _operationCts?.Dispose(); + _operationCts = new CancellationTokenSource(); + return _operationCts.Token; + } + + private void ClearOperationToken() + { + _operationCts?.Dispose(); + _operationCts = null; + } + + public void Dispose() + { + _operationCts?.Cancel(); + _operationCts?.Dispose(); + _operationCts = null; + + _resendTimer?.Dispose(); + _resendTimer = null; + } + + private sealed class OtpInputModel + { + [Required(ErrorMessage = "???? ???? ??? ???? ?????? ???.")] + [RegularExpression(@"^\\d{5,6}$", ErrorMessage = "??? ???? ???? ? ?? ? ??? ????.")] + public string Code { get; set; } = string.Empty; + } +} + + +