feat: username/password authentication for admin and merchant panels
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
- Add PasswordHasher utility (PBKDF2/SHA-256, 100k iterations)
- Add Username + PasswordHash fields to Employee and SystemAdmin entities
- EF migration: AddPasswordLogin (nullable columns on both tables)
- Meezi.API: POST /api/auth/login (employee password login, CHOOSE_CAFE support)
- Meezi.API: PUT/DELETE /api/cafes/{id}/employees/{id}/credentials (Owner/Manager only)
- Meezi.Admin.API: POST /api/admin/auth/login + PUT /api/admin/auth/password
- Dashboard login page: OTP / Password tabs
- Admin login page: OTP / Password tabs
- HR screen: new Credentials tab for setting employee username/password
- PlatformDataSeeder: ensure system admin + integration settings in production
- Trial countdown banner: updated deadline to 1 Tir 1405 (Jun 22)
- i18n: fa/en/ar updated for all new UI strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -403,6 +403,61 @@ public class AuthService : IAuthService
|
||||
return slug;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
|
||||
LoginWithPasswordRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
|
||||
var candidates = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
.Where(e => e.Username == username
|
||||
&& e.PasswordHash != null
|
||||
&& e.DeletedAt == null
|
||||
&& e.Cafe.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||
|
||||
// Constant-time verification (check all matches to avoid username enumeration)
|
||||
var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();
|
||||
|
||||
if (matched.Count == 0)
|
||||
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||
|
||||
// Scope to a specific café if requested
|
||||
if (!string.IsNullOrWhiteSpace(request.CafeId))
|
||||
{
|
||||
matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
|
||||
if (matched.Count == 0)
|
||||
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||
}
|
||||
|
||||
// Multiple cafés — ask frontend to pick one
|
||||
if (matched.Count > 1)
|
||||
{
|
||||
var choices = new CafeChoicesResponse(
|
||||
matched
|
||||
.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 = matched[0];
|
||||
if (employee.Cafe is null)
|
||||
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||
|
||||
var membershipDtos = matched
|
||||
.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, null, cancellationToken);
|
||||
return (true, tokens, null, null, null);
|
||||
}
|
||||
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.Employee employee,
|
||||
Core.Entities.Cafe cafe,
|
||||
|
||||
@@ -16,6 +16,10 @@ public interface IAuthService
|
||||
VerifyOtpRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
|
||||
LoginWithPasswordRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||
string employeeId, string targetCafeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Reference in New Issue
Block a user