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:
@@ -38,6 +38,26 @@ public class AuthController : ControllerBase
|
||||
_verifyRegisterValidator = verifyRegisterValidator;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
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, choices) = await _authService.LoginWithPasswordAsync(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("send-otp")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||
@@ -193,6 +213,9 @@ public class AuthController : 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
|
||||
{
|
||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Hr;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
|
||||
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
||||
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
||||
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public HrController(
|
||||
IHrService hr,
|
||||
IValidator<CreateLeaveRequest> leaveValidator,
|
||||
IValidator<ReviewLeaveRequest> reviewValidator,
|
||||
IValidator<CreateSalaryRequest> salaryValidator)
|
||||
IValidator<CreateSalaryRequest> salaryValidator,
|
||||
AppDbContext db)
|
||||
{
|
||||
_hr = hr;
|
||||
_leaveValidator = leaveValidator;
|
||||
_reviewValidator = reviewValidator;
|
||||
_salaryValidator = salaryValidator;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("employees")]
|
||||
@@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||
}
|
||||
|
||||
/// <summary>Set or update username/password credentials for an employee. Owner/Manager only.</summary>
|
||||
[HttpPut("employees/{employeeId}/credentials")]
|
||||
public async Task<IActionResult> SetCredentials(
|
||||
string cafeId,
|
||||
string employeeId,
|
||||
[FromBody] SetEmployeeCredentialsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
var username = request.Username.Trim().ToLowerInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username")));
|
||||
|
||||
if (request.Password.Length < 8)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password")));
|
||||
|
||||
var employee = await _db.Employees
|
||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||
|
||||
if (employee is null) return NotFoundError();
|
||||
|
||||
// Check username uniqueness within the cafe (excluding the employee itself)
|
||||
var conflict = await _db.Employees
|
||||
.AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null
|
||||
&& e.Username != null && e.Username.ToLower() == username, ct);
|
||||
if (conflict)
|
||||
return Conflict(new ApiResponse<object>(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.")));
|
||||
|
||||
employee.Username = username;
|
||||
employee.PasswordHash = PasswordHasher.Hash(request.Password);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, null));
|
||||
}
|
||||
|
||||
/// <summary>Remove username/password credentials from an employee. Owner/Manager only.</summary>
|
||||
[HttpDelete("employees/{employeeId}/credentials")]
|
||||
public async Task<IActionResult> RemoveCredentials(
|
||||
string cafeId,
|
||||
string employeeId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
var employee = await _db.Employees
|
||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||
|
||||
if (employee is null) return NotFoundError();
|
||||
|
||||
employee.Username = null;
|
||||
employee.PasswordHash = null;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new ApiResponse<object>(true, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth;
|
||||
|
||||
public record SendOtpRequest(string Phone);
|
||||
|
||||
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
|
||||
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
|
||||
|
||||
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||
|
||||
public record RefreshTokenRequest(string RefreshToken);
|
||||
|
||||
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
|
||||
decimal Deductions);
|
||||
|
||||
public record TodayShiftDto(ShiftType ShiftType, string Label);
|
||||
|
||||
/// <summary>Set or update username/password credentials for an employee.</summary>
|
||||
public record SetEmployeeCredentialsRequest(string Username, string Password);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
|
||||
public decimal BaseSalary { get; set; }
|
||||
public string? PinCode { get; set; }
|
||||
|
||||
/// <summary>Optional username for password-based dashboard/POS login (set by cafe admin).</summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
public Cafe Cafe { get; set; } = null!;
|
||||
public Branch? Branch { get; set; }
|
||||
public ICollection<Order> Orders { get; set; } = [];
|
||||
|
||||
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Optional username for password-based login (alternative to OTP).</summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Meezi.Core.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// PBKDF2/SHA-256 password hashing with no external dependencies.
|
||||
/// Format stored: "{iterations}.{salt_b64}.{hash_b64}"
|
||||
/// </summary>
|
||||
public static class PasswordHasher
|
||||
{
|
||||
private const int SaltSize = 16; // 128-bit salt
|
||||
private const int HashSize = 32; // 256-bit hash
|
||||
private const int Iterations = 100_000; // NIST-recommended minimum
|
||||
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
|
||||
|
||||
public static string Hash(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize);
|
||||
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public static bool Verify(string password, string storedHash)
|
||||
{
|
||||
var parts = storedHash.Split('.');
|
||||
if (parts.Length != 3) return false;
|
||||
if (!int.TryParse(parts[0], out var iterations)) return false;
|
||||
|
||||
byte[] salt, expectedHash;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(parts[1]);
|
||||
expectedHash = Convert.FromBase64String(parts[2]);
|
||||
}
|
||||
catch (FormatException) { return false; }
|
||||
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
|
||||
}
|
||||
}
|
||||
+3299
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPasswordLogin : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PasswordHash",
|
||||
table: "SystemAdmins",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Username",
|
||||
table: "SystemAdmins",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PasswordHash",
|
||||
table: "Employees",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Username",
|
||||
table: "Employees",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PasswordHash",
|
||||
table: "SystemAdmins");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Username",
|
||||
table: "SystemAdmins");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PasswordHash",
|
||||
table: "Employees");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Username",
|
||||
table: "Employees");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -929,6 +929,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Property<string>("NationalId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -939,6 +942,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
@@ -2119,11 +2125,17 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
|
||||
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Platform;
|
||||
using Meezi.Core.Utilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
|
||||
public static async Task SeedAsync(IServiceProvider services)
|
||||
{
|
||||
var env = services.GetRequiredService<IHostEnvironment>();
|
||||
if (!env.IsDevelopment())
|
||||
return;
|
||||
|
||||
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
|
||||
await EnsureCatalogUpgradesAsync(db, logger);
|
||||
// Production-safe: ensure the platform owner's system-admin account exists
|
||||
// on every boot (ALL environments) so the admin panel is reachable on a
|
||||
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
|
||||
await EnsureOwnerAdminAsync(db, config, logger);
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
{
|
||||
// Production: also ensure integration settings (Kavenegar enabled/template,
|
||||
// etc.) exist so the admin Integrations page is populated. Idempotent.
|
||||
await EnsureIntegrationSettingsAsync(db, logger);
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureCatalogUpgradesAsync(db, logger);
|
||||
await SeedSystemAdminAsync(db, logger);
|
||||
await SeedPlansAsync(db, logger);
|
||||
await SeedFeaturesAsync(db, logger);
|
||||
@@ -36,6 +45,49 @@ public static class PlatformDataSeeder
|
||||
await EnsureIntegrationSettingsAsync(db, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the platform owner's system-admin account exists in EVERY environment
|
||||
/// (including production), so the admin panel is reachable on a fresh deploy.
|
||||
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
|
||||
/// and defaults to the platform owner's number. Idempotent — never duplicates.
|
||||
/// </summary>
|
||||
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
|
||||
{
|
||||
const string DefaultOwnerPhone = "09190345606";
|
||||
var configured = config["Seed:SystemAdminPhone"];
|
||||
var phone = PhoneNormalizer.Normalize(
|
||||
string.IsNullOrWhiteSpace(configured) ? DefaultOwnerPhone : configured);
|
||||
|
||||
if (!PhoneNormalizer.IsValidIranMobile(phone))
|
||||
{
|
||||
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await db.SystemAdmins.AnyAsync(a => a.Phone == phone))
|
||||
return;
|
||||
|
||||
db.SystemAdmins.Add(new SystemAdmin
|
||||
{
|
||||
Id = "sysadmin_owner",
|
||||
Name = "مدیر سامانه",
|
||||
Phone = phone,
|
||||
IsActive = true
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Seeded owner system admin with phone {Phone}", phone);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// api + admin-api boot concurrently against the same DB; another instance
|
||||
// already inserted this admin. Safe to ignore.
|
||||
logger.LogInformation("Owner system admin already seeded by another instance");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
|
||||
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
||||
{
|
||||
@@ -126,7 +178,7 @@ public static class PlatformDataSeeder
|
||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
||||
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
|
||||
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
|
||||
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||
@@ -296,7 +348,7 @@ public static class PlatformDataSeeder
|
||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
||||
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
|
||||
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
|
||||
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||
|
||||
Reference in New Issue
Block a user