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
@@ -1,8 +1,11 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Meezi.Admin.API.Controllers;
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("login")]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPut("password")]
[Authorize]
public async Task<IActionResult> ChangePassword(
[FromBody] ChangePasswordRequest request,
CancellationToken cancellationToken)
{
var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(adminId))
return Unauthorized();
var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
@@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) =>
code switch
{
+4
View File
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
public record RefreshTokenRequest(string RefreshToken);
public record LoginWithPasswordRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
@@ -19,6 +19,15 @@ public interface IAdminAuthService
VerifyOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default);
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
return (true, tokens, null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
if (!PasswordHasher.Verify(request.Password, admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
var tokens = await IssueTokensAsync(admin, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default)
{
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null)
return (false, "NOT_FOUND", "Admin not found.");
// If a password is already set, require the current one
if (!string.IsNullOrWhiteSpace(admin.PasswordHash))
{
if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash))
return (false, "INVALID_CREDENTIALS", "Current password is incorrect.");
}
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
return (false, "VALIDATION_ERROR", "New password must be at least 8 characters.");
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
await _db.SaveChangesAsync(cancellationToken);
return (true, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.SystemAdmin admin,
CancellationToken cancellationToken)