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
@@ -38,6 +38,26 @@ public class AuthController : ControllerBase
_verifyRegisterValidator = verifyRegisterValidator; _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")] [HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")] [EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)] [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)); 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 private IActionResult ErrorResult(string code, string message) => code switch
{ {
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests, "RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
+69 -1
View File
@@ -1,9 +1,12 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr; using Meezi.API.Models.Hr;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Shared; using Meezi.Shared;
namespace Meezi.API.Controllers; namespace Meezi.API.Controllers;
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
private readonly IValidator<CreateLeaveRequest> _leaveValidator; private readonly IValidator<CreateLeaveRequest> _leaveValidator;
private readonly IValidator<ReviewLeaveRequest> _reviewValidator; private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
private readonly IValidator<CreateSalaryRequest> _salaryValidator; private readonly IValidator<CreateSalaryRequest> _salaryValidator;
private readonly AppDbContext _db;
public HrController( public HrController(
IHrService hr, IHrService hr,
IValidator<CreateLeaveRequest> leaveValidator, IValidator<CreateLeaveRequest> leaveValidator,
IValidator<ReviewLeaveRequest> reviewValidator, IValidator<ReviewLeaveRequest> reviewValidator,
IValidator<CreateSalaryRequest> salaryValidator) IValidator<CreateSalaryRequest> salaryValidator,
AppDbContext db)
{ {
_hr = hr; _hr = hr;
_leaveValidator = leaveValidator; _leaveValidator = leaveValidator;
_reviewValidator = reviewValidator; _reviewValidator = reviewValidator;
_salaryValidator = salaryValidator; _salaryValidator = salaryValidator;
_db = db;
} }
[HttpGet("employees")] [HttpGet("employees")]
@@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data)); 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));
}
} }
+3
View File
@@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth;
public record SendOtpRequest(string Phone); 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 VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
public record RefreshTokenRequest(string RefreshToken); public record RefreshTokenRequest(string RefreshToken);
+3
View File
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
decimal Deductions); decimal Deductions);
public record TodayShiftDto(ShiftType ShiftType, string Label); 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);
+55
View File
@@ -403,6 +403,61 @@ public class AuthService : IAuthService
return slug; 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( private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.Employee employee, Core.Entities.Employee employee,
Core.Entities.Cafe cafe, Core.Entities.Cafe cafe,
+4
View File
@@ -16,6 +16,10 @@ public interface IAuthService
VerifyOtpRequest request, VerifyOtpRequest request,
CancellationToken cancellationToken = default); 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( Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
string employeeId, string targetCafeId, string employeeId, string targetCafeId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -1,8 +1,11 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models; using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services; using Meezi.Admin.API.Services;
using Meezi.Shared; using Meezi.Shared;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Meezi.Admin.API.Controllers; namespace Meezi.Admin.API.Controllers;
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data)); 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")] [HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) 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)); 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) => private IActionResult ErrorResult(string code, string message) =>
code switch code switch
{ {
+4
View File
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
public record RefreshTokenRequest(string RefreshToken); public record RefreshTokenRequest(string RefreshToken);
public record LoginWithPasswordRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record AuthTokenResponse( public record AuthTokenResponse(
string AccessToken, string AccessToken,
string RefreshToken, string RefreshToken,
@@ -19,6 +19,15 @@ public interface IAdminAuthService
VerifyOtpRequest request, VerifyOtpRequest request,
CancellationToken cancellationToken = default); 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( Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request, RefreshTokenRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
return (true, tokens, null, null); 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( private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.SystemAdmin admin, Core.Entities.SystemAdmin admin,
CancellationToken cancellationToken) CancellationToken cancellationToken)
+6
View File
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
public decimal BaseSalary { get; set; } public decimal BaseSalary { get; set; }
public string? PinCode { 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 Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public ICollection<Order> Orders { get; set; } = []; public ICollection<Order> Orders { get; set; } = [];
+6
View File
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty; public string Phone { get; set; } = string.Empty;
public bool IsActive { get; set; } = true; 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);
}
}
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") b.Property<string>("NationalId")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -939,6 +942,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<int>("Role") b.Property<int>("Role")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("BranchId"); b.HasIndex("BranchId");
@@ -2119,11 +2125,17 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Phone") b.HasIndex("Phone")
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Platform; using Meezi.Core.Platform;
using Meezi.Core.Utilities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
public static async Task SeedAsync(IServiceProvider services) public static async Task SeedAsync(IServiceProvider services)
{ {
var env = services.GetRequiredService<IHostEnvironment>(); var env = services.GetRequiredService<IHostEnvironment>();
if (!env.IsDevelopment())
return;
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder"); var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await using var scope = services.CreateAsyncScope(); await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); 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()) 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; return;
}
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger); await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger); await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger); await SeedFeaturesAsync(db, logger);
@@ -36,6 +45,49 @@ public static class PlatformDataSeeder
await EnsureIntegrationSettingsAsync(db, logger); 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> /// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services) public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
{ {
@@ -126,7 +178,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"), S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"), S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"), 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.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"), S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"), S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
@@ -296,7 +348,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"), S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"), S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"), 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.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"), S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"), S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
+8 -1
View File
@@ -1093,7 +1093,14 @@
"otp": "رمز التحقق", "otp": "رمز التحقق",
"login": "دخول", "login": "دخول",
"error": "فشل تسجيل الدخول", "error": "فشل تسجيل الدخول",
"devHint": "في التطوير يُطبع الرمز في سجل Admin API." "devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
}, },
"dashboard": { "dashboard": {
"title": "نظرة عامة", "title": "نظرة عامة",
+8 -1
View File
@@ -1086,7 +1086,14 @@
"otp": "Verification code", "otp": "Verification code",
"login": "Sign in", "login": "Sign in",
"error": "Login failed", "error": "Login failed",
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP)." "devHint": "In development the OTP is logged by Admin API (DEV admin OTP).",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password."
}, },
"dashboard": { "dashboard": {
"title": "Platform overview", "title": "Platform overview",
+8 -1
View File
@@ -1086,7 +1086,14 @@
"otp": "کد تأیید", "otp": "کد تأیید",
"login": "ورود", "login": "ورود",
"error": "خطا در ورود", "error": "خطا در ورود",
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP)." "devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP).",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
}, },
"dashboard": { "dashboard": {
"title": "خلاصه سامانه", "title": "خلاصه سامانه",
+119 -8
View File
@@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type LoginTab = "otp" | "password";
export default function AdminLoginPage() { export default function AdminLoginPage() {
const t = useTranslations("admin.auth"); const t = useTranslations("admin.auth");
const tAuth = useTranslations("auth"); const tAuth = useTranslations("auth");
const router = useRouter(); const router = useRouter();
const setAuth = useAdminAuthStore((s) => s.setAuth); const setAuth = useAdminAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09120000001"); const [phone, setPhone] = useState("09120000001");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone"); const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
case "INVALID_OTP": case "INVALID_OTP":
case "VALIDATION_ERROR": case "VALIDATION_ERROR":
return tAuth("invalidOtp"); return tAuth("invalidOtp");
case "INVALID_TOKEN":
return t("invalidCredentials");
default: default:
return err.message; return err.message;
} }
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
setError(null); setError(null);
try { try {
await adminPost("/api/admin/auth/send-otp", { phone }); await adminPost("/api/admin/auth/send-otp", { phone });
setStep("otp"); setOtpStep("otp");
setCode(""); setCode("");
} catch (e) { } catch (e) {
setError(authErrorMessage(e)); setError(authErrorMessage(e));
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
} }
}; };
const verify = async () => { const verifyOtp = async () => {
const normalized = normalizeOtpInput(code); const normalized = normalizeOtpInput(code);
if (normalized.length !== 6) { if (normalized.length !== 6) {
setError(tAuth("invalidOtp")); setError(tAuth("invalidOtp"));
@@ -77,6 +90,34 @@ export default function AdminLoginPage() {
} }
}; };
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/admin");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl"> <div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p> <p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? ( {/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
{loading ? "..." : t("sendOtp")} {loading ? "..." : t("sendOtp")}
</Button> </Button>
</form> </form>
) : ( )}
{tab === "otp" && otpStep === "otp" && (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (!loading) void verify(); if (!loading) void verifyOtp();
}} }}
> >
<LabeledField label={t("otp")} htmlFor="admin-login-otp"> <LabeledField label={t("otp")} htmlFor="admin-login-otp">
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
className="w-full" className="w-full"
disabled={loading} disabled={loading}
onClick={() => { onClick={() => {
setStep("phone"); setOtpStep("phone");
setCode(""); setCode("");
setError(null); setError(null);
}} }}
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
</Button> </Button>
</form> </form>
)} )}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="admin-login-username">
<Input
id="admin-login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="admin-login-password">
<Input
id="admin-login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading || !username.trim() || !password}>
{loading ? "..." : t("login")}
</Button>
</form>
)}
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null} {error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
</CardContent> </CardContent>
</Card> </Card>
+26 -3
View File
@@ -45,7 +45,14 @@
"chooseCafe": "اختر المقهى", "chooseCafe": "اختر المقهى",
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.", "chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
"createNewCafe": "إنشاء مقهى جديد", "createNewCafe": "إنشاء مقهى جديد",
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟" "createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
}, },
"roles": { "roles": {
"owner": "المالك", "owner": "المالك",
@@ -386,7 +393,8 @@
"attendance": "الحضور", "attendance": "الحضور",
"leave": "الإجازة", "leave": "الإجازة",
"payroll": "الرواتب", "payroll": "الرواتب",
"access": "صلاحيات الفروع" "access": "صلاحيات الفروع",
"credentials": "بيانات الدخول"
}, },
"myAttendance": "حضوري", "myAttendance": "حضوري",
"clockIn": "تسجيل دخول", "clockIn": "تسجيل دخول",
@@ -396,7 +404,22 @@
"paid": "مدفوع", "paid": "مدفوع",
"markPaid": "تسجيل الدفع", "markPaid": "تسجيل الدفع",
"employeeCount": "الموظفون", "employeeCount": "الموظفون",
"monthYear": "شهر الرواتب" "monthYear": "شهر الرواتب",
"credentials": {
"title": "بيانات دخول الموظفين",
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
"selectEmployee": "اختر موظفاً أولاً",
"username": "اسم المستخدم",
"usernamePlaceholder": "مثال: ali_barista",
"password": "كلمة المرور (8 أحرف على الأقل)",
"passwordPlaceholder": "كلمة مرور جديدة",
"set": "حفظ بيانات الدخول",
"remove": "حذف بيانات الدخول",
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
"saved": "تم حفظ بيانات الدخول.",
"removed": "تم حذف بيانات الدخول.",
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
}
}, },
"reviews": { "reviews": {
"title": "تقييمات العملاء", "title": "تقييمات العملاء",
+26 -3
View File
@@ -56,7 +56,14 @@
"chooseCafe": "Choose a café", "chooseCafe": "Choose a café",
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.", "chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
"createNewCafe": "Create a new café", "createNewCafe": "Create a new café",
"createNewCafeHint": "Want to start your own café with this number?" "createNewCafeHint": "Want to start your own café with this number?",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password."
}, },
"roles": { "roles": {
"owner": "Owner", "owner": "Owner",
@@ -405,7 +412,8 @@
"attendance": "Attendance", "attendance": "Attendance",
"leave": "Leave", "leave": "Leave",
"payroll": "Payroll", "payroll": "Payroll",
"access": "Branch access" "access": "Branch access",
"credentials": "Login credentials"
}, },
"myAttendance": "My attendance", "myAttendance": "My attendance",
"clockIn": "Clock in", "clockIn": "Clock in",
@@ -415,7 +423,22 @@
"paid": "Paid", "paid": "Paid",
"markPaid": "Mark paid", "markPaid": "Mark paid",
"employeeCount": "Employees", "employeeCount": "Employees",
"monthYear": "Payroll month" "monthYear": "Payroll month",
"credentials": {
"title": "Employee login credentials",
"subtitle": "Set a username and password for each employee so they can sign in without an OTP.",
"selectEmployee": "Select an employee first",
"username": "Username",
"usernamePlaceholder": "e.g. ali_barista",
"password": "Password (min 8 characters)",
"passwordPlaceholder": "New password",
"set": "Save credentials",
"remove": "Remove credentials",
"removeConfirm": "Are you sure? The employee will no longer be able to sign in with a password.",
"saved": "Credentials saved.",
"removed": "Credentials removed.",
"usernameTaken": "This username is already taken."
}
}, },
"reviews": { "reviews": {
"title": "Customer reviews", "title": "Customer reviews",
+26 -3
View File
@@ -56,7 +56,14 @@
"chooseCafe": "انتخاب کافه", "chooseCafe": "انتخاب کافه",
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.", "chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
"createNewCafe": "ایجاد کافه جدید", "createNewCafe": "ایجاد کافه جدید",
"createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟" "createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
}, },
"roles": { "roles": {
"owner": "مالک", "owner": "مالک",
@@ -405,7 +412,8 @@
"attendance": "حضور و غیاب", "attendance": "حضور و غیاب",
"leave": "مرخصی", "leave": "مرخصی",
"payroll": "حقوق", "payroll": "حقوق",
"access": "دسترسی شعب" "access": "دسترسی شعب",
"credentials": "رمز ورود"
}, },
"myAttendance": "حضور من", "myAttendance": "حضور من",
"clockIn": "ورود", "clockIn": "ورود",
@@ -415,7 +423,22 @@
"paid": "پرداخت شده", "paid": "پرداخت شده",
"markPaid": "ثبت پرداخت", "markPaid": "ثبت پرداخت",
"employeeCount": "تعداد کارمندان", "employeeCount": "تعداد کارمندان",
"monthYear": "ماه حقوق" "monthYear": "ماه حقوق",
"credentials": {
"title": "مدیریت رمز ورود کارمندان",
"subtitle": "برای هر کارمند می‌توانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.",
"selectEmployee": "ابتدا یک کارمند انتخاب کنید",
"username": "نام کاربری",
"usernamePlaceholder": "مثال: ali_barista",
"password": "رمز عبور (حداقل ۸ کاراکتر)",
"passwordPlaceholder": "رمز عبور جدید",
"set": "ذخیره رمز ورود",
"remove": "حذف رمز ورود",
"removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمی‌تواند با رمز عبور وارد شود.",
"saved": "رمز ورود ذخیره شد.",
"removed": "رمز ورود حذف شد.",
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
}
}, },
"reviews": { "reviews": {
"title": "نظرات مشتریان", "title": "نظرات مشتریان",
+125 -6
View File
@@ -12,14 +12,24 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { OtpInput } from "@/components/ui/otp-input"; import { OtpInput } from "@/components/ui/otp-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type LoginTab = "otp" | "password";
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const router = useRouter(); const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth); const setAuth = useAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09121234567"); const [phone, setPhone] = useState("09121234567");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone"); const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,6 +42,9 @@ export default function LoginPage() {
return t("smsFailed"); return t("smsFailed");
case "INVALID_OTP": case "INVALID_OTP":
return t("invalidOtp"); return t("invalidOtp");
case "INVALID_TOKEN":
case "NOT_FOUND":
return tab === "password" ? t("invalidCredentials") : t("notFound");
default: default:
return err.message; return err.message;
} }
@@ -44,7 +57,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await apiPost("/api/auth/send-otp", { phone }); await apiPost("/api/auth/send-otp", { phone });
setStep("otp"); setOtpStep("otp");
} catch (e) { } catch (e) {
if (e instanceof ApiClientError && e.code === "NOT_FOUND") { if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
// No account → take them to register with phone pre-filled // No account → take them to register with phone pre-filled
@@ -74,6 +87,34 @@ export default function LoginPage() {
} }
}; };
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await apiPost<AuthTokenResponse>("/api/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/pos");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4"> <div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
@@ -81,8 +122,36 @@ export default function LoginPage() {
<CardTitle className="text-center text-primary">{t("title")}</CardTitle> <CardTitle className="text-center text-primary">{t("title")}</CardTitle>
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? ( {/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
@@ -105,7 +174,9 @@ export default function LoginPage() {
{loading ? "..." : t("sendOtp")} {loading ? "..." : t("sendOtp")}
</Button> </Button>
</form> </form>
) : ( )}
{tab === "otp" && otpStep === "otp" && (
<form <form
className="space-y-4" className="space-y-4"
onSubmit={(e) => { onSubmit={(e) => {
@@ -128,12 +199,60 @@ export default function LoginPage() {
type="button" type="button"
variant="ghost" variant="ghost"
className="w-full" className="w-full"
onClick={() => setStep("phone")} onClick={() => {
setOtpStep("phone");
setCode("");
setError(null);
}}
> >
{t("resend")} {t("resend")}
</Button> </Button>
</form> </form>
)} )}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="login-username">
<Input
id="login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="login-password">
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button
type="submit"
className="w-full"
disabled={loading || !username.trim() || !password}
>
{loading ? "..." : t("verify")}
</Button>
</form>
)}
{error && ( {error && (
<p className="text-center text-sm text-destructive">{error}</p> <p className="text-center text-sm text-destructive">{error}</p>
)} )}
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiPut, apiDelete, ApiClientError } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Employee {
id: string;
name: string;
phone: string;
role: string;
}
interface Props {
cafeId: string;
employees: Employee[];
}
export function EmployeeCredentialsPanel({ cafeId, employees }: Props) {
const t = useTranslations("hr.credentials");
const [selectedId, setSelectedId] = useState<string>("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [feedback, setFeedback] = useState<{ ok: boolean; msg: string } | null>(null);
const setMutation = useMutation({
mutationFn: () =>
apiPut(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`, {
username,
password,
}),
onSuccess: () => {
setFeedback({ ok: true, msg: t("saved") });
setPassword("");
},
onError: (err) => {
if (err instanceof ApiClientError && err.code === "USERNAME_TAKEN") {
setFeedback({ ok: false, msg: t("usernameTaken") });
} else {
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
}
},
});
const removeMutation = useMutation({
mutationFn: () =>
apiDelete(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`),
onSuccess: () => {
setFeedback({ ok: true, msg: t("removed") });
setUsername("");
setPassword("");
},
onError: (err) => {
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
},
});
const handleRemove = () => {
if (!window.confirm(t("removeConfirm"))) return;
setFeedback(null);
removeMutation.mutate();
};
const isPending = setMutation.isPending || removeMutation.isPending;
return (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground mb-3">{t("subtitle")}</p>
</div>
{/* Employee selector */}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{employees.map((emp) => (
<button
key={emp.id}
type="button"
onClick={() => {
setSelectedId(emp.id);
setUsername("");
setPassword("");
setFeedback(null);
}}
className={`rounded-lg border p-3 text-start transition-colors cursor-pointer ${
selectedId === emp.id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<p className="font-medium text-sm">{emp.name}</p>
<p className="text-xs text-muted-foreground" dir="ltr">{emp.phone}</p>
</button>
))}
</div>
{/* Form */}
{selectedId && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{employees.find((e) => e.id === selectedId)?.name}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LabeledField label={t("username")} htmlFor="cred-username">
<Input
id="cred-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="off"
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="cred-password">
<Input
id="cred-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="new-password"
/>
</LabeledField>
{feedback && (
<p className={`text-sm ${feedback.ok ? "text-green-600" : "text-destructive"}`}>
{feedback.msg}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setFeedback(null);
setMutation.mutate();
}}
disabled={isPending || !username.trim() || password.length < 8}
>
{isPending ? "..." : t("set")}
</Button>
<Button
variant="outline"
onClick={handleRemove}
disabled={isPending}
>
{t("remove")}
</Button>
</div>
</CardContent>
</Card>
)}
{!selectedId && (
<p className="text-sm text-muted-foreground">{t("selectEmployee")}</p>
)}
</div>
);
}
@@ -12,6 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { BranchAccessPanel } from "@/components/hr/branch-access-panel"; import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
interface Employee { interface Employee {
id: string; id: string;
@@ -47,7 +48,7 @@ interface Salary {
isPaid: boolean; isPaid: boolean;
} }
type Tab = "attendance" | "leave" | "payroll" | "access"; type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
export function HrScreen() { export function HrScreen() {
const t = useTranslations("hr"); const t = useTranslations("hr");
@@ -122,8 +123,8 @@ export function HrScreen() {
<h2 className="text-xl font-bold">{t("title")}</h2> <h2 className="text-xl font-bold">{t("title")}</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter( {((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
(key) => key !== "access" || canManageAccess (key) => (key !== "access" && key !== "credentials") || canManageAccess
)).map((key) => ( )).map((key) => (
<Button <Button
key={key} key={key}
@@ -230,6 +231,10 @@ export function HrScreen() {
)} )}
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />} {tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
{tab === "credentials" && canManageAccess && (
<EmployeeCredentialsPanel cafeId={cafeId} employees={employees} />
)}
</div> </div>
); );
} }
@@ -5,8 +5,8 @@ import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { Clock, X, Zap } from "lucide-react"; import { Clock, X, Zap } from "lucide-react";
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30) // 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
const DEADLINE = new Date("2026-06-04T00:00:00+03:30"); const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
const STORAGE_KEY = "meezi_trial_banner_v1"; const STORAGE_KEY = "meezi_trial_banner_v1";
interface TimeLeft { interface TimeLeft {
@@ -78,11 +78,11 @@ export function TrialCountdownBanner() {
const textFa = expired const textFa = expired
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید." ? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵"; : "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
const textEn = expired const textEn = expired
? "Your Meezi trial has ended. Choose a plan to continue." ? "Your Meezi trial has ended. Choose a plan to continue."
: "Free trial ends 14 Khordad 1405 (Jun 4)"; : "Free trial ends 1 Tir 1405 (Jun 22)";
const Digit = ({ value, label }: { value: number; label: string }) => ( const Digit = ({ value, label }: { value: number; label: string }) => (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">