feat : kavenegar otp added
This commit is contained in:
+1
-1
@@ -75,7 +75,7 @@ ZARINPAL_SANDBOX=false
|
|||||||
|
|
||||||
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
||||||
# Empty = OTP is logged to API console (fine for dev, not for production)
|
# Empty = OTP is logged to API console (fine for dev, not for production)
|
||||||
KAVENEGAR_API_KEY=
|
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
||||||
|
|
||||||
# ── Snappfood webhook ─────────────────────────────────────────────────────────
|
# ── Snappfood webhook ─────────────────────────────────────────────────────────
|
||||||
SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret
|
SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:5002/dotnet/sdk:10.0}
|
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0}
|
||||||
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0}
|
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0}
|
||||||
container_name: meezi-admin-api
|
container_name: meezi-admin-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -52,8 +52,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
||||||
NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081}
|
NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081}
|
||||||
container_name: meezi-admin-web
|
container_name: meezi-admin-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+7
-7
@@ -57,7 +57,7 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:5002/dotnet/sdk:10.0}
|
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0}
|
||||||
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0}
|
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0}
|
||||||
container_name: meezi-api
|
container_name: meezi-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -103,8 +103,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
||||||
container_name: meezi-web
|
container_name: meezi-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -124,8 +124,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
||||||
container_name: meezi-website
|
container_name: meezi-website
|
||||||
@@ -148,8 +148,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:5000/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-http://mirror:8081/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_FINDER_URL:-http://localhost:3103}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_FINDER_URL:-http://localhost:3103}
|
||||||
container_name: meezi-finder
|
container_name: meezi-finder
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=node:20-alpine
|
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/admin/package*.json ./
|
COPY web/admin/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
# Install deps then ensure Alpine (musl) SWC binary is present
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
||||||
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=node:20-alpine
|
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/finder/package*.json ./
|
COPY web/finder/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=node:20-alpine
|
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/dashboard/package*.json ./
|
COPY web/dashboard/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=node:20-alpine
|
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/website/package*.json ./
|
COPY web/website/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
# Install deps then ensure Alpine (musl) SWC binary is present
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
||||||
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
||||||
|
|||||||
+3
-5
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- NuGet config for Docker builds — routes restores through local Nexus mirror.
|
<!-- NuGet config for Docker builds — routes restores through Liara NuGet mirror. -->
|
||||||
"mirror" resolves to the Docker host via extra_hosts in docker-compose build sections.
|
|
||||||
DO NOT use for local development (Nexus must be running on the server). -->
|
|
||||||
<configuration>
|
<configuration>
|
||||||
<packageSources>
|
<packageSources>
|
||||||
<clear />
|
<clear />
|
||||||
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json"
|
<add key="liara-nuget" value="https://package-mirror.liara.ir/repository/nuget/index.json"
|
||||||
protocolVersion="3" allowInsecureConnections="true" />
|
protocolVersion="3" />
|
||||||
</packageSources>
|
</packageSources>
|
||||||
<config>
|
<config>
|
||||||
<add key="http_retry_count" value="8" />
|
<add key="http_retry_count" value="8" />
|
||||||
|
|||||||
@@ -19,17 +19,23 @@ public class AuthController : ControllerBase
|
|||||||
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
|
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
|
||||||
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
|
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
|
||||||
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
|
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
|
||||||
|
private readonly IValidator<RegisterRequest> _registerValidator;
|
||||||
|
private readonly IValidator<VerifyRegisterRequest> _verifyRegisterValidator;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
IValidator<SendOtpRequest> sendOtpValidator,
|
IValidator<SendOtpRequest> sendOtpValidator,
|
||||||
IValidator<VerifyOtpRequest> verifyOtpValidator,
|
IValidator<VerifyOtpRequest> verifyOtpValidator,
|
||||||
IValidator<RefreshTokenRequest> refreshValidator)
|
IValidator<RefreshTokenRequest> refreshValidator,
|
||||||
|
IValidator<RegisterRequest> registerValidator,
|
||||||
|
IValidator<VerifyRegisterRequest> verifyRegisterValidator)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_sendOtpValidator = sendOtpValidator;
|
_sendOtpValidator = sendOtpValidator;
|
||||||
_verifyOtpValidator = verifyOtpValidator;
|
_verifyOtpValidator = verifyOtpValidator;
|
||||||
_refreshValidator = refreshValidator;
|
_refreshValidator = refreshValidator;
|
||||||
|
_registerValidator = registerValidator;
|
||||||
|
_verifyRegisterValidator = verifyRegisterValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("send-otp")]
|
[HttpPost("send-otp")]
|
||||||
@@ -78,6 +84,37 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[EnableRateLimiting("auth-otp")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await _registerValidator.ValidateAsync(request, cancellationToken);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _authService.RegisterAsync(request, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<SendOtpResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("verify-register")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> VerifyRegister([FromBody] VerifyRegisterRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await _verifyRegisterValidator.ValidateAsync(request, cancellationToken);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _authService.VerifyRegisterAsync(request, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
@@ -120,6 +157,7 @@ public class AuthController : ControllerBase
|
|||||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
|
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
|
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null)
|
|||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
|
|
||||||
|
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
|
||||||
|
public record RegisterRequest(string Phone, string CafeName);
|
||||||
|
|
||||||
|
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
||||||
|
public record VerifyRegisterRequest(string Phone, string Code);
|
||||||
|
|
||||||
public record AuthTokenResponse(
|
public record AuthTokenResponse(
|
||||||
string AccessToken,
|
string AccessToken,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Meezi.API.Models.Auth;
|
using Meezi.API.Models.Auth;
|
||||||
using Meezi.API.Security;
|
using Meezi.API.Security;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -162,6 +164,139 @@ public class AuthService : IAuthService
|
|||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync(
|
||||||
|
RegisterRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||||
|
var cafeName = request.CafeName.Trim();
|
||||||
|
|
||||||
|
var redis = _redis.GetDatabase();
|
||||||
|
var maxAttempts = _configuration.GetValue("Auth:MaxOtpAttemptsPerHour", DefaultMaxOtpAttemptsPerHour);
|
||||||
|
|
||||||
|
if (_http.HttpContext is not null)
|
||||||
|
{
|
||||||
|
var ip = ClientIpResolver.GetClientIp(_http.HttpContext);
|
||||||
|
var ipCheck = await _abuse.CheckAuthOtpByIpAsync(ip, cancellationToken);
|
||||||
|
if (!ipCheck.Allowed)
|
||||||
|
return (false, null, ipCheck.ErrorCode, ipCheck.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this phone already owns a cafe — suggest login instead
|
||||||
|
var alreadyOwner = await _db.Employees
|
||||||
|
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
|
||||||
|
if (alreadyOwner)
|
||||||
|
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
|
||||||
|
|
||||||
|
var attemptsKey = $"otp:attempts:{phone}";
|
||||||
|
if (maxAttempts > 0)
|
||||||
|
{
|
||||||
|
var attempts = await redis.StringGetAsync(attemptsKey);
|
||||||
|
if (attempts.HasValue && (int)attempts >= maxAttempts)
|
||||||
|
return (false, null, "RATE_LIMITED", "Too many OTP requests. Try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||||
|
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
|
// Store the cafe name alongside the OTP so verify-register can create the cafe
|
||||||
|
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
|
||||||
|
_logger.LogWarning("DEV REGISTER OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to send registration OTP SMS");
|
||||||
|
return (false, null, "SMS_FAILED", "Could not send verification code.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxAttempts > 0)
|
||||||
|
{
|
||||||
|
var newAttempts = await redis.StringIncrementAsync(attemptsKey);
|
||||||
|
if (newAttempts == 1)
|
||||||
|
await redis.KeyExpireAsync(attemptsKey, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Registration OTP sent for phone ending {Suffix}", phone[^4..]);
|
||||||
|
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync(
|
||||||
|
VerifyRegisterRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||||
|
var code = OtpNormalizer.Normalize(request.Code);
|
||||||
|
if (!OtpNormalizer.IsValidSixDigitCode(code))
|
||||||
|
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||||
|
|
||||||
|
var redis = _redis.GetDatabase();
|
||||||
|
|
||||||
|
var storedOtp = await redis.StringGetAsync($"otp:{phone}");
|
||||||
|
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||||
|
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||||
|
|
||||||
|
var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(cafeName))
|
||||||
|
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
|
||||||
|
|
||||||
|
// Double-check no owner was created in the meantime (race condition guard)
|
||||||
|
var alreadyOwner = await _db.Employees
|
||||||
|
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
|
||||||
|
if (alreadyOwner)
|
||||||
|
{
|
||||||
|
await redis.KeyDeleteAsync($"otp:{phone}");
|
||||||
|
await redis.KeyDeleteAsync($"reg_meta:{phone}");
|
||||||
|
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique slug
|
||||||
|
var slug = await GenerateUniqueSlugAsync(cancellationToken);
|
||||||
|
|
||||||
|
var cafe = new Cafe
|
||||||
|
{
|
||||||
|
Name = cafeName,
|
||||||
|
Slug = slug,
|
||||||
|
PreferredLanguage = "fa",
|
||||||
|
PlanTier = PlanTier.Free,
|
||||||
|
};
|
||||||
|
|
||||||
|
var owner = new Employee
|
||||||
|
{
|
||||||
|
CafeId = cafe.Id,
|
||||||
|
Name = cafeName, // owner display name defaults to cafe name until they update it
|
||||||
|
Phone = phone,
|
||||||
|
Role = EmployeeRole.Owner,
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.Cafes.Add(cafe);
|
||||||
|
_db.Employees.Add(owner);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
await redis.KeyDeleteAsync($"otp:{phone}");
|
||||||
|
await redis.KeyDeleteAsync($"reg_meta:{phone}");
|
||||||
|
|
||||||
|
_logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]);
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(owner, cafe, cancellationToken);
|
||||||
|
return (true, tokens, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
string slug;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
// e.g. "cafe-a3f9b2c"
|
||||||
|
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
|
||||||
|
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.Employee employee,
|
Core.Entities.Employee employee,
|
||||||
Core.Entities.Cafe cafe,
|
Core.Entities.Cafe cafe,
|
||||||
|
|||||||
@@ -15,4 +15,12 @@ public interface IAuthService
|
|||||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
|
||||||
RefreshTokenRequest request,
|
RefreshTokenRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, SendOtpResponse? Data, string? ErrorCode, string? ErrorMessage)> RegisterAsync(
|
||||||
|
RegisterRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyRegisterAsync(
|
||||||
|
VerifyRegisterRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,3 +37,34 @@ public class RefreshTokenRequestValidator : AbstractValidator<RefreshTokenReques
|
|||||||
RuleFor(x => x.RefreshToken).NotEmpty();
|
RuleFor(x => x.RefreshToken).NotEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
||||||
|
{
|
||||||
|
public RegisterRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Phone)
|
||||||
|
.NotEmpty()
|
||||||
|
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
|
||||||
|
.WithMessage("Invalid Iranian mobile number.");
|
||||||
|
|
||||||
|
RuleFor(x => x.CafeName)
|
||||||
|
.NotEmpty()
|
||||||
|
.MaximumLength(100)
|
||||||
|
.WithMessage("Cafe name must be between 1 and 100 characters.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VerifyRegisterRequestValidator : AbstractValidator<VerifyRegisterRequest>
|
||||||
|
{
|
||||||
|
public VerifyRegisterRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Phone)
|
||||||
|
.NotEmpty()
|
||||||
|
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
|
||||||
|
.WithMessage("Invalid Iranian mobile number.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Code)
|
||||||
|
.Must(OtpNormalizer.IsValidSixDigitCode)
|
||||||
|
.WithMessage("OTP must be 6 digits.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
|
|||||||
.WithMessage("OTP must be 6 digits.");
|
.WithMessage("OTP must be 6 digits.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class RefreshTokenRequestValidator : AbstractValidator<RefreshTokenRequest>
|
||||||
|
{
|
||||||
|
public RefreshTokenRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RefreshToken).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public static class DemoMenuCatalog
|
|||||||
|
|
||||||
public static IReadOnlyList<CategorySeed> Categories { get; } =
|
public static IReadOnlyList<CategorySeed> Categories { get; } =
|
||||||
[
|
[
|
||||||
|
new("cat_demo_coffee", "قهوه تخصصی", "Specialty Coffee", "قهوة متخصصة", 0, IconPresetId: "drinks-hot"),
|
||||||
new("cat_demo_drinks", "نوشیدنی گرم", "Hot drinks", "مشروبات ساخنة", 1, IconPresetId: "drinks-hot"),
|
new("cat_demo_drinks", "نوشیدنی گرم", "Hot drinks", "مشروبات ساخنة", 1, IconPresetId: "drinks-hot"),
|
||||||
new("cat_demo_cold", "نوشیدنی سرد", "Cold drinks", "مشروبات باردة", 2, IconPresetId: "drinks-cold"),
|
new("cat_demo_cold", "نوشیدنی سرد", "Cold drinks", "مشروبات باردة", 2, IconPresetId: "drinks-cold"),
|
||||||
new("cat_demo_breakfast", "صبحانه", "Breakfast", "فطور", 3, IconPresetId: "breakfast"),
|
new("cat_demo_breakfast", "صبحانه", "Breakfast", "فطور", 3, IconPresetId: "breakfast"),
|
||||||
@@ -47,6 +48,28 @@ public static class DemoMenuCatalog
|
|||||||
|
|
||||||
public static IReadOnlyList<ItemSeed> Items { get; } =
|
public static IReadOnlyList<ItemSeed> Items { get; } =
|
||||||
[
|
[
|
||||||
|
// ── Specialty Coffee (20 items) ──────────────────────────────────────
|
||||||
|
new("item_demo_cf_espresso", "cat_demo_coffee", "اسپرسو", "Espresso", "إسبريسو", "دوبل شات، تازه آسیاب", 65_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_doppio", "cat_demo_coffee", "دوپیو", "Doppio", "دوبيو", "دو شات اسپرسو", 80_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_ristretto", "cat_demo_coffee", "ریسترتو", "Ristretto", "ريستريتو", "کوتاه و غلیظ", 75_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_lungo", "cat_demo_coffee", "لونگو", "Lungo", "لونغو", "اسپرسو کشیده", 70_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_americano", "cat_demo_coffee", "آمریکانو", "Americano", "أمريكانو", "اسپرسو و آب داغ", 75_000, 0, "cappuccino"),
|
||||||
|
new("item_demo_cf_cappuccino", "cat_demo_coffee", "کاپوچینو", "Cappuccino", "كابتشينو", "شیر بخار و فوم", 110_000, 10, "cappuccino"),
|
||||||
|
new("item_demo_cf_latte", "cat_demo_coffee", "لاته", "Latte", "لاتيه", "شیر بخار گرفته", 120_000, 0, "latte"),
|
||||||
|
new("item_demo_cf_flat_white", "cat_demo_coffee", "فلت وایت", "Flat White", "فلات وايت", "میکرو فوم نرم", 115_000, 0, "latte"),
|
||||||
|
new("item_demo_cf_cortado", "cat_demo_coffee", "کورتادو", "Cortado", "كورتادو", "نسبت مساوی قهوه و شیر", 95_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_macchiato", "cat_demo_coffee", "ماکیاتو", "Macchiato", "ماكياتو", "اسپرسو با کمی فوم", 90_000, 0, "cappuccino"),
|
||||||
|
new("item_demo_cf_mocha", "cat_demo_coffee", "موکا", "Mocha", "موكا", "شکلات تلخ و اسپرسو", 135_000, 0, "mocha"),
|
||||||
|
new("item_demo_cf_affogato", "cat_demo_coffee", "افوگاتو", "Affogato", "أفوغاتو", "اسپرسو روی بستنی وانیل", 140_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_cold_brew", "cat_demo_coffee", "کولد برو", "Cold Brew", "كولد برو", "دم ۱۲ ساعته", 140_000, 0, "iced_coffee"),
|
||||||
|
new("item_demo_cf_iced_latte", "cat_demo_coffee", "آیس لاته", "Iced Latte", "آيس لاتيه", "سرد و خنک", 130_000, 0, "iced_coffee"),
|
||||||
|
new("item_demo_cf_iced_ameri", "cat_demo_coffee", "آیس آمریکانو", "Iced Americano", "آيس أمريكانو", null, 95_000, 0, "iced_coffee"),
|
||||||
|
new("item_demo_cf_nitro", "cat_demo_coffee", "نیترو کافی", "Nitro Cold Brew", "نيترو كافي", "با نیتروژن کربندار", 155_000, 0, "iced_coffee"),
|
||||||
|
new("item_demo_cf_v60", "cat_demo_coffee", "V60", "Pour Over V60", "في 60", "دمآوری دستی", 145_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_aeropress", "cat_demo_coffee", "ایروپرس", "AeroPress", "إيروبريس", "طعم نرم و کامل", 140_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_turkish", "cat_demo_coffee", "قهوه ترک", "Turkish Coffee", "قهوة تركية", "سنتی با هل", 80_000, 0, "espresso"),
|
||||||
|
new("item_demo_cf_specialty", "cat_demo_coffee", "قهوه تکخاستگاه", "Single Origin", "أحادي المنشأ", "هر هفته از منشأ جدید", 165_000, 0, "espresso"),
|
||||||
|
|
||||||
// Hot drinks
|
// Hot drinks
|
||||||
new("item_demo_espresso", "cat_demo_drinks", "اسپرسو", "Espresso", "إسبريسو", "دوبل یا سینگل", 65_000, 0, "espresso"),
|
new("item_demo_espresso", "cat_demo_drinks", "اسپرسو", "Espresso", "إسبريسو", "دوبل یا سینگل", 65_000, 0, "espresso"),
|
||||||
new("item_demo_americano", "cat_demo_drinks", "آمریکانو", "Americano", "أمريكانو", null, 75_000, 0, "cappuccino"),
|
new("item_demo_americano", "cat_demo_drinks", "آمریکانو", "Americano", "أمريكانو", null, 75_000, 0, "cappuccino"),
|
||||||
@@ -129,6 +152,10 @@ file static class DemoMenuCategoryKinds
|
|||||||
|
|
||||||
public static MenuItemVisualKind KindFor(string itemId, string food101Class)
|
public static MenuItemVisualKind KindFor(string itemId, string food101Class)
|
||||||
{
|
{
|
||||||
|
// All specialty coffee items are drinks
|
||||||
|
if (itemId.StartsWith("item_demo_cf_", StringComparison.Ordinal))
|
||||||
|
return MenuItemVisualKind.Drink;
|
||||||
|
|
||||||
if (itemId.Contains("demo_iced", StringComparison.Ordinal)
|
if (itemId.Contains("demo_iced", StringComparison.Ordinal)
|
||||||
|| itemId.Contains("demo_cold", StringComparison.Ordinal)
|
|| itemId.Contains("demo_cold", StringComparison.Ordinal)
|
||||||
|| itemId.Contains("demo_lemonade", StringComparison.Ordinal)
|
|| itemId.Contains("demo_lemonade", StringComparison.Ordinal)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public static class DevelopmentDataSeeder
|
|||||||
CafeId = cafe.Id,
|
CafeId = cafe.Id,
|
||||||
BranchId = "branch_demo_main",
|
BranchId = "branch_demo_main",
|
||||||
Name = "مدیر دمو",
|
Name = "مدیر دمو",
|
||||||
Phone = "09121234567",
|
Phone = "09212273138",
|
||||||
Role = EmployeeRole.Owner,
|
Role = EmployeeRole.Owner,
|
||||||
BaseSalary = 0
|
BaseSalary = 0
|
||||||
};
|
};
|
||||||
@@ -97,10 +97,12 @@ public static class DevelopmentDataSeeder
|
|||||||
await SeedDemoOpenShiftsAsync(db, cafe.Id, logger);
|
await SeedDemoOpenShiftsAsync(db, cafe.Id, logger);
|
||||||
|
|
||||||
var ownerEmp = await db.Employees.FirstOrDefaultAsync(e => e.Id == "emp_demo_owner");
|
var ownerEmp = await db.Employees.FirstOrDefaultAsync(e => e.Id == "emp_demo_owner");
|
||||||
if (ownerEmp is not null && ownerEmp.BranchId is null)
|
if (ownerEmp is not null)
|
||||||
{
|
{
|
||||||
ownerEmp.BranchId = "branch_demo_main";
|
var changed = false;
|
||||||
await db.SaveChangesAsync();
|
if (ownerEmp.BranchId is null) { ownerEmp.BranchId = "branch_demo_main"; changed = true; }
|
||||||
|
if (ownerEmp.Phone != "09212273138") { ownerEmp.Phone = "09212273138"; changed = true; }
|
||||||
|
if (changed) await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
await DemoEmployeesSeeder.EnsureEmployeesAsync(db, cafe.Id, logger);
|
await DemoEmployeesSeeder.EnsureEmployeesAsync(db, cafe.Id, logger);
|
||||||
|
|||||||
@@ -41,7 +41,18 @@
|
|||||||
"rateLimited": "Too many code requests. Wait up to one hour or contact support.",
|
"rateLimited": "Too many code requests. Wait up to one hour or contact support.",
|
||||||
"notFound": "No account found for this mobile number.",
|
"notFound": "No account found for this mobile number.",
|
||||||
"smsFailed": "Could not send SMS. Please try again.",
|
"smsFailed": "Could not send SMS. Please try again.",
|
||||||
"invalidOtp": "Invalid or expired verification code."
|
"invalidOtp": "Invalid or expired verification code.",
|
||||||
|
"register": "Create account",
|
||||||
|
"registerSubtitle": "Register your cafe on Meezi",
|
||||||
|
"cafeName": "Cafe or restaurant name",
|
||||||
|
"cafeNamePlaceholder": "e.g. Roya Cafe",
|
||||||
|
"createAccount": "Create account",
|
||||||
|
"alreadyHaveAccount": "Already have an account?",
|
||||||
|
"loginLink": "Sign in",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"registerLink": "Register",
|
||||||
|
"alreadyRegistered": "This phone is already registered. Please sign in.",
|
||||||
|
"registrationExpired": "Registration session expired. Please try again."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "Main navigation",
|
"aria": "Main navigation",
|
||||||
|
|||||||
@@ -41,7 +41,18 @@
|
|||||||
"rateLimited": "تعداد درخواست کد بیش از حد است. حداکثر یک ساعت صبر کنید یا با پشتیبانی تماس بگیرید.",
|
"rateLimited": "تعداد درخواست کد بیش از حد است. حداکثر یک ساعت صبر کنید یا با پشتیبانی تماس بگیرید.",
|
||||||
"notFound": "حسابی با این شماره موبایل یافت نشد.",
|
"notFound": "حسابی با این شماره موبایل یافت نشد.",
|
||||||
"smsFailed": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.",
|
"smsFailed": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.",
|
||||||
"invalidOtp": "کد تأیید نادرست یا منقضی شده است."
|
"invalidOtp": "کد تأیید نادرست یا منقضی شده است.",
|
||||||
|
"register": "ثبتنام",
|
||||||
|
"registerSubtitle": "کافه خود را در میزی ثبت کنید",
|
||||||
|
"cafeName": "نام کافه یا رستوران",
|
||||||
|
"cafeNamePlaceholder": "مثال: کافه رویا",
|
||||||
|
"createAccount": "ایجاد حساب",
|
||||||
|
"alreadyHaveAccount": "حساب دارید؟",
|
||||||
|
"loginLink": "ورود",
|
||||||
|
"noAccount": "حساب ندارید؟",
|
||||||
|
"registerLink": "ثبتنام",
|
||||||
|
"alreadyRegistered": "این شماره قبلاً ثبتنام کرده است. لطفاً وارد شوید.",
|
||||||
|
"registrationExpired": "زمان ثبتنام منقضی شد. دوباره تلاش کنید."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "منوی اصلی",
|
"aria": "منوی اصلی",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter, Link } from "@/i18n/routing";
|
||||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
@@ -27,8 +27,6 @@ export default function LoginPage() {
|
|||||||
switch (err.code) {
|
switch (err.code) {
|
||||||
case "RATE_LIMITED":
|
case "RATE_LIMITED":
|
||||||
return t("rateLimited");
|
return t("rateLimited");
|
||||||
case "NOT_FOUND":
|
|
||||||
return t("notFound");
|
|
||||||
case "SMS_FAILED":
|
case "SMS_FAILED":
|
||||||
return t("smsFailed");
|
return t("smsFailed");
|
||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
@@ -47,6 +45,11 @@ export default function LoginPage() {
|
|||||||
await apiPost("/api/auth/send-otp", { phone });
|
await apiPost("/api/auth/send-otp", { phone });
|
||||||
setStep("otp");
|
setStep("otp");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
|
||||||
|
// No account → take them to register with phone pre-filled
|
||||||
|
router.push(`/register?phone=${encodeURIComponent(phone)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(authErrorMessage(e));
|
setError(authErrorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -137,6 +140,13 @@ export default function LoginPage() {
|
|||||||
{error && (
|
{error && (
|
||||||
<p className="text-center text-sm text-destructive">{error}</p>
|
<p className="text-center text-sm text-destructive">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{t("noAccount")}{" "}
|
||||||
|
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||||
|
{t("registerLink")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter, Link } from "@/i18n/routing";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||||
|
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
function RegisterForm() {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const router = useRouter();
|
||||||
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [phone, setPhone] = useState(searchParams.get("phone") ?? "");
|
||||||
|
const [cafeName, setCafeName] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [step, setStep] = useState<"info" | "otp">("info");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const errorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof ApiClientError) {
|
||||||
|
switch (err.code) {
|
||||||
|
case "RATE_LIMITED": return t("rateLimited");
|
||||||
|
case "ALREADY_REGISTERED": return t("alreadyRegistered");
|
||||||
|
case "SMS_FAILED": return t("smsFailed");
|
||||||
|
case "INVALID_OTP": return t("invalidOtp");
|
||||||
|
case "REGISTRATION_EXPIRED": return t("registrationExpired");
|
||||||
|
default: return err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendOtp = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiPost("/api/auth/register", { phone, cafeName });
|
||||||
|
setStep("otp");
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOtp = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await apiPost<AuthTokenResponse>("/api/auth/verify-register", { phone, code });
|
||||||
|
setAuth(data);
|
||||||
|
router.push("/");
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center text-primary">{t("register")}</CardTitle>
|
||||||
|
<p className="text-center text-sm text-muted-foreground">{t("registerSubtitle")}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{step === "info" ? (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) void sendOtp();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabeledField label={t("cafeName")} htmlFor="reg-cafe-name">
|
||||||
|
<Input
|
||||||
|
id="reg-cafe-name"
|
||||||
|
value={cafeName}
|
||||||
|
onChange={(e) => setCafeName(e.target.value)}
|
||||||
|
placeholder={t("cafeNamePlaceholder")}
|
||||||
|
autoComplete="organization"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("phone")} htmlFor="reg-phone">
|
||||||
|
<Input
|
||||||
|
id="reg-phone"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder={t("phonePlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-end"
|
||||||
|
autoComplete="tel"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "..." : t("sendOtp")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) void verifyOtp();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabeledField label={t("otp")} htmlFor="reg-otp">
|
||||||
|
<Input
|
||||||
|
id="reg-otp"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder={t("otpPlaceholder")}
|
||||||
|
maxLength={6}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-center tracking-widest"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "..." : t("createAccount")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => { setStep("info"); setCode(""); }}
|
||||||
|
>
|
||||||
|
{t("resend")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-center text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{t("alreadyHaveAccount")}{" "}
|
||||||
|
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||||
|
{t("loginLink")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<RegisterForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// Middleware handles locale routing, but if it ever misses, redirect to /fa
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/fa");
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
import { routing } from "./i18n/routing";
|
||||||
|
|
||||||
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match all pathnames except Next.js internals and static files
|
||||||
|
matcher: ["/((?!_next|_vercel|.*\\..*).*)"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user