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;
+ }
+}
+
+
+