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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user