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

- 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:
soroush.asadi
2026-05-31 19:58:54 +03:30
parent d0117f3171
commit 639d5c305e
27 changed files with 4257 additions and 40 deletions
+55
View File
@@ -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,
+4
View File
@@ -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);