Add OTP login flow and multi-cafe role switching
Introduce an OTP input box on login/register, surface user roles and a cafe chooser, add a dashboard switch button in the POS screen, and register OTP validators explicitly to survive Docker layer caching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Meezi.API.Models.Auth;
|
using Meezi.API.Models.Auth;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Constants;
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -62,7 +63,28 @@ public class AuthController : ControllerBase
|
|||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
return BadRequest(ValidationError(validation));
|
return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
var (success, data, code, message) = await _authService.VerifyOtpAsync(request, cancellationToken);
|
var (success, data, code, message, choices) = await _authService.VerifyOtpAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!success && code == "CHOOSE_CAFE")
|
||||||
|
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("switch-cafe")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> SwitchCafe([FromBody] SwitchCafeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _authService.SwitchCafeAsync(userId, request.CafeId, cancellationToken);
|
||||||
if (!success)
|
if (!success)
|
||||||
return ErrorResult(code!, message!);
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null)
|
|||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
|
|
||||||
|
public record SwitchCafeRequest(string CafeId);
|
||||||
|
|
||||||
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
|
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
|
||||||
public record RegisterRequest(string Phone, string CafeName);
|
public record RegisterRequest(string Phone, string CafeName);
|
||||||
|
|
||||||
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
||||||
public record VerifyRegisterRequest(string Phone, string Code);
|
public record VerifyRegisterRequest(string Phone, string Code);
|
||||||
|
|
||||||
|
/// <summary>One café membership entry returned when user belongs to multiple cafés.</summary>
|
||||||
|
public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier);
|
||||||
|
|
||||||
public record AuthTokenResponse(
|
public record AuthTokenResponse(
|
||||||
string AccessToken,
|
string AccessToken,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
@@ -22,6 +27,10 @@ public record AuthTokenResponse(
|
|||||||
string PlanTier,
|
string PlanTier,
|
||||||
string Language,
|
string Language,
|
||||||
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
||||||
string? BranchId = null);
|
string? BranchId = null,
|
||||||
|
List<CafeMembershipDto>? Memberships = null);
|
||||||
|
|
||||||
public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
|
public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
|
||||||
|
|
||||||
|
/// <summary>Returned when a phone number belongs to multiple cafés and no CafeId was specified.</summary>
|
||||||
|
public record CafeChoicesResponse(List<CafeMembershipDto> Cafes);
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ public class AuthService : IAuthService
|
|||||||
var otp = Random.Shared.Next(100000, 999999).ToString();
|
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||||
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"]))
|
|
||||||
_logger.LogWarning("DEV OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||||
@@ -105,20 +102,20 @@ public class AuthService : IAuthService
|
|||||||
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
|
return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> VerifyOtpAsync(
|
||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var phone = PhoneNormalizer.Normalize(request.Phone);
|
var phone = PhoneNormalizer.Normalize(request.Phone);
|
||||||
var code = OtpNormalizer.Normalize(request.Code);
|
var code = OtpNormalizer.Normalize(request.Code);
|
||||||
if (!OtpNormalizer.IsValidSixDigitCode(code))
|
if (!OtpNormalizer.IsValidSixDigitCode(code))
|
||||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null);
|
||||||
|
|
||||||
var redis = _redis.GetDatabase();
|
var redis = _redis.GetDatabase();
|
||||||
|
|
||||||
var storedOtp = await redis.StringGetAsync($"otp:{phone}");
|
var storedOtp = await redis.StringGetAsync($"otp:{phone}");
|
||||||
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null);
|
||||||
|
|
||||||
var query = _db.Employees
|
var query = _db.Employees
|
||||||
.Include(e => e.Cafe)
|
.Include(e => e.Cafe)
|
||||||
@@ -129,17 +126,68 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
var matches = await query.ToListAsync(cancellationToken);
|
var matches = await query.ToListAsync(cancellationToken);
|
||||||
if (matches.Count == 0)
|
if (matches.Count == 0)
|
||||||
return (false, null, "NOT_FOUND", "No account found for this phone number.");
|
return (false, null, "NOT_FOUND", "No account found for this phone number.", null);
|
||||||
|
|
||||||
|
// Multiple cafés — ask frontend to pick one (OTP kept alive for the 2nd call)
|
||||||
if (matches.Count > 1)
|
if (matches.Count > 1)
|
||||||
return (false, null, "MULTIPLE_ACCOUNTS", "Multiple accounts use this phone. Contact your cafe owner.");
|
{
|
||||||
|
var choices = new CafeChoicesResponse(
|
||||||
|
matches
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList());
|
||||||
|
return (false, null, "CHOOSE_CAFE", null, choices);
|
||||||
|
}
|
||||||
|
|
||||||
var employee = matches[0];
|
var employee = matches[0];
|
||||||
if (employee.Cafe is null)
|
if (employee.Cafe is null)
|
||||||
return (false, null, "NOT_FOUND", "No account found for this phone number.");
|
return (false, null, "NOT_FOUND", "No account found for this phone number.", null);
|
||||||
|
|
||||||
await redis.KeyDeleteAsync($"otp:{phone}");
|
await redis.KeyDeleteAsync($"otp:{phone}");
|
||||||
|
|
||||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, cancellationToken);
|
// Fetch all memberships for this phone to include in the token response
|
||||||
|
var allMemberships = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.Where(e => e.Phone == phone && e.DeletedAt == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var membershipDtos = allMemberships
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken);
|
||||||
|
return (true, tokens, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||||
|
string employeeId, string targetCafeId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Find the current employee to get their phone
|
||||||
|
var currentEmployee = await _db.Employees
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.DeletedAt == null, cancellationToken);
|
||||||
|
if (currentEmployee is null)
|
||||||
|
return (false, null, "NOT_FOUND", "User not found.");
|
||||||
|
|
||||||
|
// Find their membership in the target café
|
||||||
|
var targetEmployee = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.FirstOrDefaultAsync(e => e.Phone == currentEmployee.Phone && e.CafeId == targetCafeId && e.DeletedAt == null, cancellationToken);
|
||||||
|
if (targetEmployee?.Cafe is null)
|
||||||
|
return (false, null, "NOT_FOUND", "You don't have access to this café.");
|
||||||
|
|
||||||
|
var allMemberships = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.Where(e => e.Phone == currentEmployee.Phone && e.DeletedAt == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var membershipDtos = allMemberships
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, cancellationToken);
|
||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +208,17 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||||
|
|
||||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, cancellationToken);
|
var allMemberships = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.Where(e => e.Phone == employee.Phone && e.DeletedAt == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var membershipDtos = allMemberships
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken);
|
||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +259,6 @@ public class AuthService : IAuthService
|
|||||||
// Store the cafe name alongside the OTP so verify-register can create the cafe
|
// Store the cafe name alongside the OTP so verify-register can create the cafe
|
||||||
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));
|
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
|
try
|
||||||
{
|
{
|
||||||
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||||
@@ -282,7 +337,11 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
_logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]);
|
_logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]);
|
||||||
|
|
||||||
var tokens = await IssueTokensAsync(owner, cafe, cancellationToken);
|
var ownerMembership = new List<CafeMembershipDto>
|
||||||
|
{
|
||||||
|
new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString())
|
||||||
|
};
|
||||||
|
var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, cancellationToken);
|
||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +359,7 @@ public class AuthService : IAuthService
|
|||||||
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,
|
||||||
|
List<CafeMembershipDto>? memberships,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe);
|
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe);
|
||||||
@@ -328,6 +388,7 @@ public class AuthService : IAuthService
|
|||||||
cafe.PlanTier.ToString(),
|
cafe.PlanTier.ToString(),
|
||||||
cafe.PreferredLanguage,
|
cafe.PreferredLanguage,
|
||||||
Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
||||||
employee.BranchId);
|
employee.BranchId,
|
||||||
|
memberships);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ public interface IAuthService
|
|||||||
SendOtpRequest request,
|
SendOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync(
|
/// <summary>
|
||||||
|
/// Returns either an AuthTokenResponse (single café) or error code CHOOSE_CAFE
|
||||||
|
/// with CafeChoicesResponse serialised in ErrorMessage when multiple cafés found.
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> VerifyOtpAsync(
|
||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||||
|
string employeeId, string targetCafeId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Meezi.Admin.API.Hubs;
|
using Meezi.Admin.API.Hubs;
|
||||||
using Meezi.Admin.API.Services;
|
using Meezi.Admin.API.Services;
|
||||||
|
using Meezi.Admin.API.Models;
|
||||||
using Meezi.Admin.API.Validators;
|
using Meezi.Admin.API.Validators;
|
||||||
using Meezi.Infrastructure;
|
using Meezi.Infrastructure;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -38,6 +39,10 @@ public static class AdminServiceCollectionExtensions
|
|||||||
services.AddSwaggerGen();
|
services.AddSwaggerGen();
|
||||||
services.AddSignalR();
|
services.AddSignalR();
|
||||||
services.AddValidatorsFromAssemblyContaining<SendOtpRequestValidator>();
|
services.AddValidatorsFromAssemblyContaining<SendOtpRequestValidator>();
|
||||||
|
// Explicit registrations as safety net (assembly scan can miss in some Docker layer caches)
|
||||||
|
services.AddScoped<IValidator<SendOtpRequest>, SendOtpRequestValidator>();
|
||||||
|
services.AddScoped<IValidator<VerifyOtpRequest>, VerifyOtpRequestValidator>();
|
||||||
|
services.AddScoped<IValidator<RefreshTokenRequest>, RefreshTokenRequestValidator>();
|
||||||
|
|
||||||
var jwtKey = configuration["Jwt:Key"] ?? "meezi-dev-secret-key-min-32-chars!!";
|
var jwtKey = configuration["Jwt:Key"] ?? "meezi-dev-secret-key-min-32-chars!!";
|
||||||
var jwtIssuer = configuration["Jwt:Issuer"] ?? "meezi";
|
var jwtIssuer = configuration["Jwt:Issuer"] ?? "meezi";
|
||||||
|
|||||||
@@ -41,7 +41,20 @@
|
|||||||
"rateLimited": "طلبات الرمز كثيرة جداً. انتظر ساعة كحد أقصى أو تواصل مع الدعم.",
|
"rateLimited": "طلبات الرمز كثيرة جداً. انتظر ساعة كحد أقصى أو تواصل مع الدعم.",
|
||||||
"notFound": "لا يوجد حساب بهذا الرقم.",
|
"notFound": "لا يوجد حساب بهذا الرقم.",
|
||||||
"smsFailed": "فشل إرسال الرسالة. حاول مرة أخرى.",
|
"smsFailed": "فشل إرسال الرسالة. حاول مرة أخرى.",
|
||||||
"invalidOtp": "رمز التحقق غير صحيح أو منتهٍ."
|
"invalidOtp": "رمز التحقق غير صحيح أو منتهٍ.",
|
||||||
|
"chooseCafe": "اختر المقهى",
|
||||||
|
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
||||||
|
"createNewCafe": "إنشاء مقهى جديد",
|
||||||
|
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"owner": "المالك",
|
||||||
|
"manager": "المدير",
|
||||||
|
"cashier": "أمين الصندوق",
|
||||||
|
"waiter": "النادل",
|
||||||
|
"chef": "الطاهي",
|
||||||
|
"delivery": "عامل التوصيل",
|
||||||
|
"unknown": "مستخدم"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "القائمة الرئيسية",
|
"aria": "القائمة الرئيسية",
|
||||||
|
|||||||
@@ -52,7 +52,20 @@
|
|||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
"registerLink": "Register",
|
"registerLink": "Register",
|
||||||
"alreadyRegistered": "This phone is already registered. Please sign in.",
|
"alreadyRegistered": "This phone is already registered. Please sign in.",
|
||||||
"registrationExpired": "Registration session expired. Please try again."
|
"registrationExpired": "Registration session expired. Please try again.",
|
||||||
|
"chooseCafe": "Choose a café",
|
||||||
|
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
|
||||||
|
"createNewCafe": "Create a new café",
|
||||||
|
"createNewCafeHint": "Want to start your own café with this number?"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"owner": "Owner",
|
||||||
|
"manager": "Manager",
|
||||||
|
"cashier": "Cashier",
|
||||||
|
"waiter": "Waiter",
|
||||||
|
"chef": "Chef",
|
||||||
|
"delivery": "Delivery",
|
||||||
|
"unknown": "User"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "Main navigation",
|
"aria": "Main navigation",
|
||||||
@@ -93,7 +106,13 @@
|
|||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"activePlan": "Active plan",
|
"activePlan": "Active plan",
|
||||||
"editCafeSettings": "Café settings",
|
"editCafeSettings": "Café settings",
|
||||||
"viewSubscription": "Plan & billing"
|
"viewSubscription": "Plan & billing",
|
||||||
|
"switchCafe": "Switch café",
|
||||||
|
"currentCafe": "Current café",
|
||||||
|
"otherCafes": "Other cafés",
|
||||||
|
"createNewCafe": "Create a new café",
|
||||||
|
"openMenu": "Menu",
|
||||||
|
"switchCafeError": "Could not switch café. Please try again."
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "Home",
|
"title": "Home",
|
||||||
|
|||||||
@@ -52,7 +52,20 @@
|
|||||||
"noAccount": "حساب ندارید؟",
|
"noAccount": "حساب ندارید؟",
|
||||||
"registerLink": "ثبتنام",
|
"registerLink": "ثبتنام",
|
||||||
"alreadyRegistered": "این شماره قبلاً ثبتنام کرده است. لطفاً وارد شوید.",
|
"alreadyRegistered": "این شماره قبلاً ثبتنام کرده است. لطفاً وارد شوید.",
|
||||||
"registrationExpired": "زمان ثبتنام منقضی شد. دوباره تلاش کنید."
|
"registrationExpired": "زمان ثبتنام منقضی شد. دوباره تلاش کنید.",
|
||||||
|
"chooseCafe": "انتخاب کافه",
|
||||||
|
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
|
||||||
|
"createNewCafe": "ایجاد کافه جدید",
|
||||||
|
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"owner": "مالک",
|
||||||
|
"manager": "مدیر",
|
||||||
|
"cashier": "صندوقدار",
|
||||||
|
"waiter": "گارسون",
|
||||||
|
"chef": "آشپز",
|
||||||
|
"delivery": "پیک",
|
||||||
|
"unknown": "کاربر"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "منوی اصلی",
|
"aria": "منوی اصلی",
|
||||||
@@ -93,7 +106,13 @@
|
|||||||
"offline": "آفلاین",
|
"offline": "آفلاین",
|
||||||
"activePlan": "پلن فعال",
|
"activePlan": "پلن فعال",
|
||||||
"editCafeSettings": "تنظیمات کافه",
|
"editCafeSettings": "تنظیمات کافه",
|
||||||
"viewSubscription": "اشتراک و پلن"
|
"viewSubscription": "اشتراک و پلن",
|
||||||
|
"switchCafe": "تغییر کافه",
|
||||||
|
"currentCafe": "کافه فعلی",
|
||||||
|
"otherCafes": "کافههای دیگر",
|
||||||
|
"createNewCafe": "ایجاد کافه جدید",
|
||||||
|
"openMenu": "منو",
|
||||||
|
"switchCafeError": "تغییر کافه ناموفق بود. دوباره تلاش کنید."
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"title": "خانه",
|
"title": "خانه",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
|
import { OtpInput } from "@/components/ui/otp-input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -113,18 +114,14 @@ export default function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LabeledField label={t("otp")} htmlFor="login-otp">
|
<LabeledField label={t("otp")} htmlFor="login-otp">
|
||||||
<Input
|
<OtpInput
|
||||||
id="login-otp"
|
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={setCode}
|
||||||
placeholder={t("otpPlaceholder")}
|
autoFocus
|
||||||
maxLength={6}
|
disabled={loading}
|
||||||
dir="ltr"
|
|
||||||
className="text-center tracking-widest"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading || code.length < 6}>
|
||||||
{loading ? "..." : t("verify")}
|
{loading ? "..." : t("verify")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
|
import { OtpInput } from "@/components/ui/otp-input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
@@ -118,18 +119,14 @@ function RegisterForm() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LabeledField label={t("otp")} htmlFor="reg-otp">
|
<LabeledField label={t("otp")} htmlFor="reg-otp">
|
||||||
<Input
|
<OtpInput
|
||||||
id="reg-otp"
|
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={setCode}
|
||||||
placeholder={t("otpPlaceholder")}
|
autoFocus
|
||||||
maxLength={6}
|
disabled={loading}
|
||||||
dir="ltr"
|
|
||||||
className="text-center tracking-widest"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading || code.length < 6}>
|
||||||
{loading ? "..." : t("createAccount")}
|
{loading ? "..." : t("createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslations, useLocale } from "next-intl";
|
|||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
LayoutDashboard,
|
||||||
Minus,
|
Minus,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -899,6 +900,19 @@ export function PosScreen() {
|
|||||||
>
|
>
|
||||||
{t("modePay")}
|
{t("modePay")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Dashboard shortcut — only visible to Owner / Manager */}
|
||||||
|
{isManager && (
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="size-4" />
|
||||||
|
<span className="hidden sm:inline">{cafeName}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
|
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, KeyboardEvent, ClipboardEvent } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OtpInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
length?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OtpInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
length = 6,
|
||||||
|
disabled = false,
|
||||||
|
autoFocus = false,
|
||||||
|
}: OtpInputProps) {
|
||||||
|
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
const digits = Array.from({ length }, (_, i) => value[i] ?? "");
|
||||||
|
|
||||||
|
const focus = (index: number) => {
|
||||||
|
inputsRef.current[index]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, char: string) => {
|
||||||
|
// Accept only digits
|
||||||
|
const digit = char.replace(/\D/g, "").slice(-1);
|
||||||
|
const next = digits.map((d, i) => (i === index ? digit : d)).join("");
|
||||||
|
onChange(next);
|
||||||
|
if (digit && index < length - 1) focus(index + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
if (digits[index]) {
|
||||||
|
const next = digits.map((d, i) => (i === index ? "" : d)).join("");
|
||||||
|
onChange(next);
|
||||||
|
} else if (index > 0) {
|
||||||
|
focus(index - 1);
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
focus(Math.max(0, index - 1));
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
focus(Math.min(length - 1, index + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
||||||
|
if (!pasted) return;
|
||||||
|
onChange(pasted.padEnd(length, "").slice(0, length).replace(/ /g, ""));
|
||||||
|
// Actually just set what was pasted
|
||||||
|
const filled = pasted.slice(0, length);
|
||||||
|
onChange(filled);
|
||||||
|
focus(Math.min(filled.length, length - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
{digits.map((digit, i) => (
|
||||||
|
<input
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { inputsRef.current[i] = el; }}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus && i === 0}
|
||||||
|
autoComplete={i === 0 ? "one-time-code" : "off"}
|
||||||
|
onChange={(e) => handleChange(i, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
className={cn(
|
||||||
|
"h-12 w-10 rounded-lg border-2 bg-background text-center text-lg font-semibold",
|
||||||
|
"transition-all duration-150 outline-none",
|
||||||
|
"border-border",
|
||||||
|
"focus:border-primary focus:ring-2 focus:ring-primary/20",
|
||||||
|
digit && "border-primary/60 bg-primary/5",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,7 +76,9 @@ export async function apiGetPaged<T>(url: string): Promise<{ items: T[]; meta: P
|
|||||||
export class ApiClientError extends Error {
|
export class ApiClientError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly code: string,
|
public readonly code: string,
|
||||||
message: string
|
message: string,
|
||||||
|
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
||||||
|
public readonly payload?: unknown
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiClientError";
|
this.name = "ApiClientError";
|
||||||
@@ -87,7 +89,7 @@ export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T>
|
|||||||
const { data } = await api.post<ApiResponse<T>>(url, body);
|
const { data } = await api.post<ApiResponse<T>>(url, body);
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
|
||||||
}
|
}
|
||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ export interface ApiResponse<T> {
|
|||||||
error?: { code: string; message: string; field?: string };
|
error?: { code: string; message: string; field?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CafeMembership {
|
||||||
|
cafeId: string;
|
||||||
|
cafeName: string;
|
||||||
|
role: string;
|
||||||
|
planTier: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthTokenResponse {
|
export interface AuthTokenResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@@ -15,6 +22,12 @@ export interface AuthTokenResponse {
|
|||||||
language: string;
|
language: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
branchId?: string | null;
|
branchId?: string | null;
|
||||||
|
memberships?: CafeMembership[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returned (in the data field) when a phone belongs to multiple cafés. */
|
||||||
|
export interface CafeChoicesResponse {
|
||||||
|
cafes: CafeMembership[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuCategory {
|
export interface MenuCategory {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Maps backend EmployeeRole names to i18n keys under the "roles" namespace.
|
||||||
|
* Backend enum: Owner, Manager, Cashier, Waiter, Chef, Delivery.
|
||||||
|
*/
|
||||||
|
export type EmployeeRoleName =
|
||||||
|
| "Owner"
|
||||||
|
| "Manager"
|
||||||
|
| "Cashier"
|
||||||
|
| "Waiter"
|
||||||
|
| "Chef"
|
||||||
|
| "Delivery";
|
||||||
|
|
||||||
|
export const ROLE_KEYS: Record<string, string> = {
|
||||||
|
Owner: "owner",
|
||||||
|
Manager: "manager",
|
||||||
|
Cashier: "cashier",
|
||||||
|
Waiter: "waiter",
|
||||||
|
Chef: "chef",
|
||||||
|
Delivery: "delivery",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function roleKey(role: string | undefined | null): string {
|
||||||
|
if (!role) return "unknown";
|
||||||
|
return ROLE_KEYS[role] ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tailwind classes for a colored role badge. */
|
||||||
|
export function roleBadgeClass(role: string | undefined | null): string {
|
||||||
|
switch (role) {
|
||||||
|
case "Owner":
|
||||||
|
return "bg-primary/10 text-primary border-primary/30";
|
||||||
|
case "Manager":
|
||||||
|
return "bg-violet-50 text-violet-700 border-violet-200";
|
||||||
|
case "Cashier":
|
||||||
|
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||||
|
case "Chef":
|
||||||
|
return "bg-amber-50 text-amber-700 border-amber-200";
|
||||||
|
case "Waiter":
|
||||||
|
return "bg-emerald-50 text-emerald-700 border-emerald-200";
|
||||||
|
case "Delivery":
|
||||||
|
return "bg-orange-50 text-orange-700 border-orange-200";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground border-border";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user