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 Meezi.API.Models.Auth;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -62,7 +63,28 @@ public class AuthController : ControllerBase
|
||||
if (!validation.IsValid)
|
||||
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)
|
||||
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 SwitchCafeRequest(string CafeId);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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(
|
||||
string AccessToken,
|
||||
string RefreshToken,
|
||||
@@ -22,6 +27,10 @@ public record AuthTokenResponse(
|
||||
string PlanTier,
|
||||
string Language,
|
||||
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
||||
string? BranchId = null);
|
||||
string? BranchId = null,
|
||||
List<CafeMembershipDto>? Memberships = null);
|
||||
|
||||
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();
|
||||
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
|
||||
{
|
||||
await _smsService.SendOtpAsync(phone, otp, cancellationToken);
|
||||
@@ -105,20 +102,20 @@ public class AuthService : IAuthService
|
||||
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,
|
||||
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.");
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null);
|
||||
|
||||
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.");
|
||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null);
|
||||
|
||||
var query = _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
@@ -129,17 +126,68 @@ public class AuthService : IAuthService
|
||||
|
||||
var matches = await query.ToListAsync(cancellationToken);
|
||||
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)
|
||||
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];
|
||||
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}");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -160,7 +208,17 @@ public class AuthService : IAuthService
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -201,9 +259,6 @@ public class AuthService : IAuthService
|
||||
// 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);
|
||||
@@ -282,7 +337,11 @@ public class AuthService : IAuthService
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -300,6 +359,7 @@ public class AuthService : IAuthService
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.Employee employee,
|
||||
Core.Entities.Cafe cafe,
|
||||
List<CafeMembershipDto>? memberships,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe);
|
||||
@@ -328,6 +388,7 @@ public class AuthService : IAuthService
|
||||
cafe.PlanTier.ToString(),
|
||||
cafe.PreferredLanguage,
|
||||
Meezi.Core.Constants.MeeziActorKinds.Merchant,
|
||||
employee.BranchId);
|
||||
employee.BranchId,
|
||||
memberships);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,18 @@ public interface IAuthService
|
||||
SendOtpRequest request,
|
||||
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,
|
||||
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(
|
||||
RefreshTokenRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Reference in New Issue
Block a user